implement continuous mode, gesture, keyboard recognition

This commit is contained in:
nyne
2024-10-09 11:16:00 +08:00
parent 12463208a6
commit 90b9265ca0
8 changed files with 860 additions and 32 deletions

View File

@@ -0,0 +1,390 @@
part of 'reader.dart';
class ComicImage extends StatefulWidget {
/// Modified from flutter Image
ComicImage({
required ImageProvider image,
super.key,
double scale = 1.0,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.filterQuality = FilterQuality.medium,
this.isAntiAlias = false,
Map<String, String>? headers,
int? cacheWidth,
int? cacheHeight,
}
): image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image),
assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0);
final ImageProvider image;
final String? semanticLabel;
final bool excludeFromSemantics;
final double? width;
final double? height;
final bool gaplessPlayback;
final bool matchTextDirection;
final Rect? centerSlice;
final ImageRepeat repeat;
final AlignmentGeometry alignment;
final BoxFit? fit;
final BlendMode? colorBlendMode;
final FilterQuality filterQuality;
final Animation<double>? opacity;
final Color? color;
final bool isAntiAlias;
static void clear() => _ComicImageState.clear();
@override
State<ComicImage> createState() => _ComicImageState();
}
class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
ImageStream? _imageStream;
ImageInfo? _imageInfo;
ImageChunkEvent? _loadingProgress;
bool _isListeningToStream = false;
late bool _invertColors;
int? _frameNumber;
bool _wasSynchronouslyLoaded = false;
late DisposableBuildContext<State<ComicImage>> _scrollAwareContext;
Object? _lastException;
ImageStreamCompleterHandle? _completerHandle;
static final Map<int, Size> _cache = {};
static clear() => _cache.clear();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_scrollAwareContext = DisposableBuildContext<State<ComicImage>>(this);
}
@override
void dispose() {
assert(_imageStream != null);
WidgetsBinding.instance.removeObserver(this);
_stopListeningToStream();
_completerHandle?.dispose();
_scrollAwareContext.dispose();
_replaceImage(info: null);
super.dispose();
}
@override
void didChangeDependencies() {
_updateInvertColors();
_resolveImage();
if (TickerMode.of(context)) {
_listenToStream();
} else {
_stopListeningToStream(keepStreamAlive: true);
}
super.didChangeDependencies();
}
@override
void didUpdateWidget(ComicImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.image != oldWidget.image) {
_resolveImage();
}
}
@override
void didChangeAccessibilityFeatures() {
super.didChangeAccessibilityFeatures();
setState(() {
_updateInvertColors();
});
}
@override
void reassemble() {
_resolveImage(); // in case the image cache was flushed
super.reassemble();
}
void _updateInvertColors() {
_invertColors = MediaQuery.maybeInvertColorsOf(context)
?? SemanticsBinding.instance.accessibilityFeatures.invertColors;
}
void _resolveImage() {
final ScrollAwareImageProvider provider = ScrollAwareImageProvider<Object>(
context: _scrollAwareContext,
imageProvider: widget.image,
);
final ImageStream newStream =
provider.resolve(createLocalImageConfiguration(
context,
size: widget.width != null && widget.height != null ? Size(widget.width!, widget.height!) : null,
));
_updateSourceStream(newStream);
}
ImageStreamListener? _imageStreamListener;
ImageStreamListener _getListener({bool recreateListener = false}) {
if(_imageStreamListener == null || recreateListener) {
_lastException = null;
_imageStreamListener = ImageStreamListener(
_handleImageFrame,
onChunk: _handleImageChunk,
onError: (Object error, StackTrace? stackTrace) {
setState(() {
_lastException = error;
});
},
);
}
return _imageStreamListener!;
}
void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) {
setState(() {
_replaceImage(info: imageInfo);
_loadingProgress = null;
_lastException = null;
_frameNumber = _frameNumber == null ? 0 : _frameNumber! + 1;
_wasSynchronouslyLoaded = _wasSynchronouslyLoaded | synchronousCall;
});
}
void _handleImageChunk(ImageChunkEvent event) {
setState(() {
_loadingProgress = event;
_lastException = null;
});
}
void _replaceImage({required ImageInfo? info}) {
final ImageInfo? oldImageInfo = _imageInfo;
SchedulerBinding.instance.addPostFrameCallback((_) => oldImageInfo?.dispose());
_imageInfo = info;
}
// Updates _imageStream to newStream, and moves the stream listener
// registration from the old stream to the new stream (if a listener was
// registered).
void _updateSourceStream(ImageStream newStream) {
if (_imageStream?.key == newStream.key) {
return;
}
if (_isListeningToStream) {
_imageStream!.removeListener(_getListener());
}
if (!widget.gaplessPlayback) {
setState(() { _replaceImage(info: null); });
}
setState(() {
_loadingProgress = null;
_frameNumber = null;
_wasSynchronouslyLoaded = false;
});
_imageStream = newStream;
if (_isListeningToStream) {
_imageStream!.addListener(_getListener());
}
}
void _listenToStream() {
if (_isListeningToStream) {
return;
}
_imageStream!.addListener(_getListener());
_completerHandle?.dispose();
_completerHandle = null;
_isListeningToStream = true;
}
/// Stops listening to the image stream, if this state object has attached a
/// listener.
///
/// If the listener from this state is the last listener on the stream, the
/// stream will be disposed. To keep the stream alive, set `keepStreamAlive`
/// to true, which create [ImageStreamCompleterHandle] to keep the completer
/// alive and is compatible with the [TickerMode] being off.
void _stopListeningToStream({bool keepStreamAlive = false}) {
if (!_isListeningToStream) {
return;
}
if (keepStreamAlive && _completerHandle == null && _imageStream?.completer != null) {
_completerHandle = _imageStream!.completer!.keepAlive();
}
_imageStream!.removeListener(_getListener());
_isListeningToStream = false;
}
@override
Widget build(BuildContext context) {
if (_lastException != null) {
// display error and retry button on screen
return SizedBox(
height: 300,
child: Center(
child: SizedBox(
height: 300,
child: Column(
children: [
Expanded(
child: Center(
child: Text(_lastException.toString(), maxLines: 3,),
),
),
const SizedBox(height: 4,),
MouseRegion(
cursor: SystemMouseCursors.click,
child: Listener(
onPointerDown: (details){
_resolveImage();
},
child: const SizedBox(
width: 84,
height: 36,
child: Center(
child: Text("Retry", style: TextStyle(color: Colors.blue),),
),
),
),
),
const SizedBox(height: 16,),
],
),
),
),
);
}
var width = widget.width??MediaQuery.of(context).size.width;
double? height;
Size? cacheSize = _cache[widget.image.hashCode];
if(cacheSize != null){
height = cacheSize.height * (width / cacheSize.width);
height = height.ceilToDouble();
}
var brightness = Theme.of(context).brightness;
if(_imageInfo != null){
// Record the height and the width of the image
_cache[widget.image.hashCode] = Size(
_imageInfo!.image.width.toDouble(),
_imageInfo!.image.height.toDouble()
);
// build image
Widget result = RawImage(
// Do not clone the image, because RawImage is a stateless wrapper.
// The image will be disposed by this state object when it is not needed
// anymore, such as when it is unmounted or when the image stream pushes
// a new image.
image: _imageInfo?.image,
debugImageLabel: _imageInfo?.debugLabel,
width: width,
height: height,
scale: _imageInfo?.scale ?? 1.0,
color: widget.color,
opacity: widget.opacity,
colorBlendMode: widget.colorBlendMode,
fit: widget.fit,
alignment: widget.alignment,
repeat: widget.repeat,
centerSlice: widget.centerSlice,
matchTextDirection: widget.matchTextDirection,
invertColors: _invertColors,
isAntiAlias: widget.isAntiAlias,
filterQuality: widget.filterQuality,
);
if (!widget.excludeFromSemantics) {
result = Semantics(
container: widget.semanticLabel != null,
image: true,
label: widget.semanticLabel ?? '',
child: result,
);
}
result = SizedBox(
width: width,
height: height,
child: Center(
child: result,
),
);
return result;
} else {
// build progress
return SizedBox(
width: width,
height: height??300,
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
backgroundColor: brightness == Brightness.dark
? Colors.white24
: Colors.black12,
strokeWidth: 3,
value: (_loadingProgress != null &&
_loadingProgress!.expectedTotalBytes!=null &&
_loadingProgress!.expectedTotalBytes! != 0)
?_loadingProgress!.cumulativeBytesLoaded / _loadingProgress!.expectedTotalBytes!
:0,
),
),
),
);
}
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description.add(DiagnosticsProperty<ImageStream>('stream', _imageStream));
description.add(DiagnosticsProperty<ImageInfo>('pixels', _imageInfo));
description.add(DiagnosticsProperty<ImageChunkEvent>('loadingProgress', _loadingProgress));
description.add(DiagnosticsProperty<int>('frameNumber', _frameNumber));
description.add(DiagnosticsProperty<bool>('wasSynchronouslyLoaded', _wasSynchronouslyLoaded));
}
}

View File

@@ -14,6 +14,8 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
static const _kDoubleTapMinTime = Duration(milliseconds: 200); static const _kDoubleTapMinTime = Duration(milliseconds: 200);
static const _kLongPressMinTime = Duration(milliseconds: 200);
static const _kDoubleTapMaxDistanceSquared = 20.0 * 20.0; static const _kDoubleTapMaxDistanceSquared = 20.0 * 20.0;
static const _kTapToTurnPagePercent = 0.3; static const _kTapToTurnPagePercent = 0.3;
@@ -33,7 +35,28 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
return Listener( return Listener(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onPointerDown: (event) { onPointerDown: (event) {
_lastTapPointer = event.pointer;
_lastTapMoveDistance = Offset.zero;
_tapGestureRecognizer.addPointer(event); _tapGestureRecognizer.addPointer(event);
Future.delayed(_kLongPressMinTime, () {
if (_lastTapPointer == event.pointer &&
_lastTapMoveDistance!.distanceSquared < 20.0 * 20.0) {
onLongPressedDown(event.position);
_longPressInProgress = true;
}
});
},
onPointerMove: (event) {
if (event.pointer == _lastTapPointer) {
_lastTapMoveDistance = event.delta + _lastTapMoveDistance!;
}
},
onPointerUp: (event) {
if (_longPressInProgress) {
onLongPressedUp(event.position);
}
_lastTapPointer = null;
_lastTapMoveDistance = null;
}, },
onPointerSignal: (event) { onPointerSignal: (event) {
if (event is PointerScrollEvent) { if (event is PointerScrollEvent) {
@@ -45,6 +68,7 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
} }
void onMouseWheel(bool forward) { void onMouseWheel(bool forward) {
if (context.reader.mode.key.startsWith('gallery')) {
if (forward) { if (forward) {
if (!context.reader.toNextPage()) { if (!context.reader.toNextPage()) {
context.reader.toNextChapter(); context.reader.toNextChapter();
@@ -55,10 +79,21 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
} }
} }
} }
}
TapUpDetails? _previousEvent; TapUpDetails? _previousEvent;
int? _lastTapPointer;
Offset? _lastTapMoveDistance;
bool _longPressInProgress = false;
void onTapUp(TapUpDetails event) { void onTapUp(TapUpDetails event) {
if (_longPressInProgress) {
_longPressInProgress = false;
return;
}
final location = event.globalPosition; final location = event.globalPosition;
final previousLocation = _previousEvent?.globalPosition; final previousLocation = _previousEvent?.globalPosition;
if (previousLocation != null) { if (previousLocation != null) {
@@ -147,19 +182,35 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
context, context,
location, location,
[ [
DesktopMenuEntry(text: "Settings".tl, onClick: () { DesktopMenuEntry(
text: "Settings".tl,
onClick: () {
context.readerScaffold.openSetting(); context.readerScaffold.openSetting();
}), }),
DesktopMenuEntry(text: "Chapters".tl, onClick: () { DesktopMenuEntry(
text: "Chapters".tl,
onClick: () {
context.readerScaffold.openChapterDrawer(); context.readerScaffold.openChapterDrawer();
}), }),
DesktopMenuEntry(text: "Fullscreen".tl, onClick: () { DesktopMenuEntry(
text: "Fullscreen".tl,
onClick: () {
context.reader.fullscreen(); context.reader.fullscreen();
}), }),
DesktopMenuEntry(text: "Exit".tl, onClick: () { DesktopMenuEntry(
text: "Exit".tl,
onClick: () {
context.pop(); context.pop();
}), }),
], ],
); );
} }
void onLongPressedUp(Offset location) {
context.reader._imageViewController?.handleLongPressUp(location);
}
void onLongPressedDown(Offset location) {
context.reader._imageViewController?.handleLongPressDown(location);
}
} }

View File

@@ -85,8 +85,7 @@ class _ReaderImagesState extends State<_ReaderImages> {
if (reader.mode.isGallery) { if (reader.mode.isGallery) {
return _GalleryMode(key: Key(reader.mode.key)); return _GalleryMode(key: Key(reader.mode.key));
} else { } else {
// TODO: Implement other modes return _ContinuousMode(key: Key(reader.mode.key));
throw UnimplementedError();
} }
} }
} }
@@ -218,6 +217,334 @@ class _GalleryModeState extends State<_GalleryMode>
var controller = photoViewControllers[reader.page]!; var controller = photoViewControllers[reader.page]!;
controller.onDoubleClick?.call(); controller.onDoubleClick?.call();
} }
@override
void handleLongPressDown(Offset location) {
var photoViewController = photoViewControllers[reader.page]!;
double target = photoViewController.getInitialScale!.call()! * 1.75;
var size = MediaQuery.of(context).size;
photoViewController.animateScale?.call(
target,
Offset(size.width / 2 - location.dx, size.height / 2 - location.dy),
);
}
@override
void handleLongPressUp(Offset location) {
var photoViewController = photoViewControllers[reader.page]!;
double target = photoViewController.getInitialScale!.call()!;
photoViewController.animateScale?.call(target);
}
@override
void handleKeyEvent(KeyEvent event) {
bool? forward;
if (reader.mode == ReaderMode.galleryLeftToRight &&
event.logicalKey == LogicalKeyboardKey.arrowRight) {
forward = true;
} else if (reader.mode == ReaderMode.galleryRightToLeft &&
event.logicalKey == LogicalKeyboardKey.arrowLeft) {
forward = true;
} else if (reader.mode == ReaderMode.galleryTopToBottom &&
event.logicalKey == LogicalKeyboardKey.arrowDown) {
forward = true;
} else if (reader.mode == ReaderMode.galleryTopToBottom &&
event.logicalKey == LogicalKeyboardKey.arrowUp) {
forward = false;
} else if (reader.mode == ReaderMode.galleryLeftToRight &&
event.logicalKey == LogicalKeyboardKey.arrowLeft) {
forward = false;
} else if (reader.mode == ReaderMode.galleryRightToLeft &&
event.logicalKey == LogicalKeyboardKey.arrowRight) {
forward = false;
}
if(event is KeyDownEvent || event is KeyRepeatEvent) {
if (forward == true) {
controller.nextPage(
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
} else if (forward == false) {
controller.previousPage(
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
}
}
}
}
const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
PointerDeviceKind.unknown
};
class _ContinuousMode extends StatefulWidget {
const _ContinuousMode({super.key});
@override
State<_ContinuousMode> createState() => _ContinuousModeState();
}
class _ContinuousModeState extends State<_ContinuousMode>
implements _ImageViewController {
late _ReaderState reader;
var itemScrollController = ItemScrollController();
var itemPositionsListener = ItemPositionsListener.create();
var photoViewController = PhotoViewController();
late ScrollController scrollController;
var isCTRLPressed = false;
static var _isMouseScrolling = false;
var fingers = 0;
@override
void initState() {
reader = context.reader;
reader._imageViewController = this;
itemPositionsListener.itemPositions.addListener(onPositionChanged);
super.initState();
}
void onPositionChanged() {
var page = itemPositionsListener.itemPositions.value.first.index;
page = page.clamp(1, reader.maxPage);
if (page != reader.page) {
reader.setPage(page);
context.readerScaffold.update();
}
}
double? futurePosition;
void smoothTo(double offset) {
futurePosition ??= scrollController.offset;
futurePosition = futurePosition! + offset * 1.2;
futurePosition = futurePosition!.clamp(
scrollController.position.minScrollExtent,
scrollController.position.maxScrollExtent,
);
scrollController.animateTo(
futurePosition!,
duration: const Duration(milliseconds: 200),
curve: Curves.linear,
);
}
void onPointerSignal(PointerSignalEvent event) {
if (event is PointerScrollEvent) {
if (!_isMouseScrolling) {
setState(() {
_isMouseScrolling = true;
});
}
if (isCTRLPressed) {
return;
}
smoothTo(event.scrollDelta.dy);
}
}
@override
Widget build(BuildContext context) {
Widget widget = ScrollablePositionedList.builder(
initialScrollIndex: reader.page,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
scrollControllerCallback: (scrollController) {
this.scrollController = scrollController;
},
itemCount: reader.maxPage + 2,
addSemanticIndexes: false,
scrollDirection: reader.mode == ReaderMode.continuousTopToBottom
? Axis.vertical
: Axis.horizontal,
reverse: reader.mode == ReaderMode.continuousRightToLeft,
physics: isCTRLPressed || _isMouseScrolling
? const NeverScrollableScrollPhysics()
: const ClampingScrollPhysics(),
itemBuilder: (context, index) {
if (index == 0 || index == reader.maxPage + 1) {
return const SizedBox();
}
double width = MediaQuery.of(context).size.width;
double height = MediaQuery.of(context).size.height;
double imageWidth = width;
if (height / width < 1.2) {
imageWidth = height / 1.2;
}
_precacheImage(index, context);
ImageProvider image = _createImageProvider(index, context);
return ComicImage(
filterQuality: FilterQuality.medium,
image: image,
width: imageWidth,
height: imageWidth * 1.2,
fit: BoxFit.cover,
);
},
scrollBehavior: const MaterialScrollBehavior()
.copyWith(scrollbars: false, dragDevices: _kTouchLikeDeviceTypes),
);
widget = Listener(
onPointerDown: (event) {
fingers++;
futurePosition = null;
if (_isMouseScrolling) {
setState(() {
_isMouseScrolling = false;
});
}
},
onPointerUp: (event) {
fingers--;
},
onPointerPanZoomUpdate: (event) {
if (event.scale == 1.0) {
smoothTo(0 - event.panDelta.dy);
}
},
onPointerMove: (event) {
Offset value = event.delta;
if (photoViewController.scale == 1 || fingers != 1) {
return;
}
if (scrollController.offset !=
scrollController.position.maxScrollExtent &&
scrollController.offset !=
scrollController.position.minScrollExtent) {
if(reader.mode == ReaderMode.continuousTopToBottom) {
value = Offset(value.dx, 0);
} else {
value = Offset(0, value.dy);
}
}
photoViewController.updateMultiple(
position: photoViewController.position + value);
},
onPointerSignal: onPointerSignal,
child: widget,
);
return PhotoView.customChild(
backgroundDecoration: BoxDecoration(
color: context.colorScheme.surface,
),
minScale: 1.0,
maxScale: 2.5,
strictScale: true,
controller: photoViewController,
child: SizedBox(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
child: widget,
),
);
}
@override
Future<void> animateToPage(int page) {
return itemScrollController.scrollTo(
index: page,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
}
@override
void handleDoubleTap(Offset location) {
double target;
if (photoViewController.scale !=
photoViewController.getInitialScale?.call()) {
target = photoViewController.getInitialScale!.call()!;
} else {
target = photoViewController.getInitialScale!.call()! * 1.75;
}
var size = MediaQuery.of(context).size;
photoViewController.animateScale?.call(
target,
Offset(size.width / 2 - location.dx, size.height / 2 - location.dy),
);
}
@override
void handleLongPressDown(Offset location) {
double target = photoViewController.getInitialScale!.call()! * 1.75;
var size = MediaQuery.of(context).size;
photoViewController.animateScale?.call(
target,
Offset(size.width / 2 - location.dx, size.height / 2 - location.dy),
);
}
@override
void handleLongPressUp(Offset location) {
double target = photoViewController.getInitialScale!.call()!;
photoViewController.animateScale?.call(target);
}
@override
void toPage(int page) {
itemScrollController.jumpTo(index: page);
futurePosition = null;
}
@override
void handleKeyEvent(KeyEvent event) {
if (event.logicalKey == LogicalKeyboardKey.controlLeft ||
event.logicalKey == LogicalKeyboardKey.controlRight) {
setState(() {
if (event is KeyDownEvent) {
isCTRLPressed = true;
} else if (event is KeyUpEvent) {
isCTRLPressed = false;
}
});
}
bool? forward;
if (reader.mode == ReaderMode.continuousLeftToRight &&
event.logicalKey == LogicalKeyboardKey.arrowRight) {
forward = true;
} else if (reader.mode == ReaderMode.continuousRightToLeft &&
event.logicalKey == LogicalKeyboardKey.arrowLeft) {
forward = true;
} else if (reader.mode == ReaderMode.continuousTopToBottom &&
event.logicalKey == LogicalKeyboardKey.arrowDown) {
forward = true;
} else if (reader.mode == ReaderMode.continuousTopToBottom &&
event.logicalKey == LogicalKeyboardKey.arrowUp) {
forward = false;
} else if (reader.mode == ReaderMode.continuousLeftToRight &&
event.logicalKey == LogicalKeyboardKey.arrowLeft) {
forward = false;
} else if (reader.mode == ReaderMode.continuousRightToLeft &&
event.logicalKey == LogicalKeyboardKey.arrowRight) {
forward = false;
}
if (forward == true) {
scrollController.animateTo(
scrollController.offset + context.height,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
} else if (forward == false) {
scrollController.animateTo(
scrollController.offset - context.height,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
}
}
} }
ImageProvider _createImageProvider(int page, BuildContext context) { ImageProvider _createImageProvider(int page, BuildContext context) {

View File

@@ -4,9 +4,12 @@ import 'dart:async';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart'; import 'package:photo_view/photo_view_gallery.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
@@ -22,10 +25,9 @@ import 'package:venera/utils/translations.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
part 'scaffold.dart'; part 'scaffold.dart';
part 'images.dart'; part 'images.dart';
part 'gesture.dart'; part 'gesture.dart';
part 'comic_image.dart';
extension _ReaderContext on BuildContext { extension _ReaderContext on BuildContext {
_ReaderState get reader => findAncestorStateOfType<_ReaderState>()!; _ReaderState get reader => findAncestorStateOfType<_ReaderState>()!;
@@ -92,6 +94,8 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
@override @override
bool isLoading = false; bool isLoading = false;
var focusNode = FocusNode();
@override @override
void initState() { void initState() {
page = widget.initialPage ?? 1; page = widget.initialPage ?? 1;
@@ -101,15 +105,31 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
super.initState(); super.initState();
} }
@override
void dispose() {
autoPageTurningTimer?.cancel();
focusNode.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _ReaderScaffold( return KeyboardListener(
focusNode: focusNode,
autofocus: true,
onKeyEvent: onKeyEvent,
child: _ReaderScaffold(
child: _ReaderGestureDetector( child: _ReaderGestureDetector(
child: _ReaderImages(key: Key(chapter.toString())), child: _ReaderImages(key: Key(chapter.toString())),
), ),
),
); );
} }
void onKeyEvent(KeyEvent event) {
_imageViewController?.handleKeyEvent(event);
}
@override @override
int get maxChapter => widget.chapters?.length ?? 1; int get maxChapter => widget.chapters?.length ?? 1;
@@ -279,4 +299,10 @@ abstract interface class _ImageViewController {
Future<void> animateToPage(int page); Future<void> animateToPage(int page);
void handleDoubleTap(Offset location); void handleDoubleTap(Offset location);
void handleLongPressDown(Offset location);
void handleLongPressUp(Offset location);
void handleKeyEvent(KeyEvent event);
} }

View File

@@ -18,6 +18,23 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
bool get isOpen => _isOpen; bool get isOpen => _isOpen;
@override
void initState() {
sliderFocus.canRequestFocus = false;
sliderFocus.addListener(() {
if (sliderFocus.hasFocus) {
sliderFocus.nextFocus();
}
});
super.initState();
}
@override
void dispose() {
sliderFocus.dispose();
super.dispose();
}
void openOrClose() { void openOrClose() {
setState(() { setState(() {
_isOpen = !_isOpen; _isOpen = !_isOpen;
@@ -248,8 +265,11 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
); );
} }
var sliderFocus = FocusNode();
Widget buildSlider() { Widget buildSlider() {
return Slider( return Slider(
focusNode: sliderFocus,
value: context.reader.page.toDouble(), value: context.reader.page.toDouble(),
min: 1, min: 1,
max: max:

View File

@@ -33,12 +33,12 @@ class _ReaderSettingsState extends State<ReaderSettings> {
title: "Reading mode".tl, title: "Reading mode".tl,
settingKey: "readerMode", settingKey: "readerMode",
optionTranslation: { optionTranslation: {
"galleryLeftToRight": "Gallery Left to Right".tl, "galleryLeftToRight": "Gallery (Left to Right)".tl,
"galleryRightToLeft": "Gallery Right to Left".tl, "galleryRightToLeft": "Gallery (Right to Left)".tl,
"galleryTopToBottom": "Gallery Top to Bottom".tl, "galleryTopToBottom": "Gallery (Top to Bottom)".tl,
"continuousLeftToRight": "Continuous Left to Right".tl, "continuousLeftToRight": "Continuous (Left to Right)".tl,
"continuousRightToLeft": "Continuous Right to Left".tl, "continuousRightToLeft": "Continuous (Right to Left)".tl,
"continuousTopToBottom": "Continuous Top to Bottom".tl, "continuousTopToBottom": "Continuous (Top to Bottom)".tl,
}, },
onChanged: () { onChanged: () {
widget.onChanged?.call("readerMode"); widget.onChanged?.call("readerMode");

View File

@@ -367,6 +367,15 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.9" version: "0.1.9"
scrollable_positioned_list:
dependency: "direct main"
description:
path: "packages/scrollable_positioned_list"
ref: "09e756b1f1b04e6298318d99ec20a787fb360f59"
resolved-ref: "09e756b1f1b04e6298318d99ec20a787fb360f59"
url: "https://github.com/venera-app/flutter.widgets"
source: git
version: "0.3.8+1"
share_plus: share_plus:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -35,6 +35,11 @@ dependencies:
ref: 94724a0b ref: 94724a0b
mime: ^1.0.5 mime: ^1.0.5
share_plus: ^10.0.2 share_plus: ^10.0.2
scrollable_positioned_list:
git:
url: https://github.com/venera-app/flutter.widgets
ref: 09e756b1f1b04e6298318d99ec20a787fb360f59
path: packages/scrollable_positioned_list
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: