mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
implement continuous mode, gesture, keyboard recognition
This commit is contained in:
390
lib/pages/reader/comic_image.dart
Normal file
390
lib/pages/reader/comic_image.dart
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
@@ -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,20 +68,32 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void onMouseWheel(bool forward) {
|
void onMouseWheel(bool forward) {
|
||||||
if (forward) {
|
if (context.reader.mode.key.startsWith('gallery')) {
|
||||||
if (!context.reader.toNextPage()) {
|
if (forward) {
|
||||||
context.reader.toNextChapter();
|
if (!context.reader.toNextPage()) {
|
||||||
}
|
context.reader.toNextChapter();
|
||||||
} else {
|
}
|
||||||
if (!context.reader.toPrevPage()) {
|
} else {
|
||||||
context.reader.toPrevChapter();
|
if (!context.reader.toPrevPage()) {
|
||||||
|
context.reader.toPrevChapter();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
||||||
context.readerScaffold.openSetting();
|
text: "Settings".tl,
|
||||||
}),
|
onClick: () {
|
||||||
DesktopMenuEntry(text: "Chapters".tl, onClick: () {
|
context.readerScaffold.openSetting();
|
||||||
context.readerScaffold.openChapterDrawer();
|
}),
|
||||||
}),
|
DesktopMenuEntry(
|
||||||
DesktopMenuEntry(text: "Fullscreen".tl, onClick: () {
|
text: "Chapters".tl,
|
||||||
context.reader.fullscreen();
|
onClick: () {
|
||||||
}),
|
context.readerScaffold.openChapterDrawer();
|
||||||
DesktopMenuEntry(text: "Exit".tl, onClick: () {
|
}),
|
||||||
context.pop();
|
DesktopMenuEntry(
|
||||||
}),
|
text: "Fullscreen".tl,
|
||||||
|
onClick: () {
|
||||||
|
context.reader.fullscreen();
|
||||||
|
}),
|
||||||
|
DesktopMenuEntry(
|
||||||
|
text: "Exit".tl,
|
||||||
|
onClick: () {
|
||||||
|
context.pop();
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void onLongPressedUp(Offset location) {
|
||||||
|
context.reader._imageViewController?.handleLongPressUp(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onLongPressedDown(Offset location) {
|
||||||
|
context.reader._imageViewController?.handleLongPressDown(location);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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) {
|
||||||
|
@@ -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(
|
||||||
child: _ReaderGestureDetector(
|
focusNode: focusNode,
|
||||||
child: _ReaderImages(key: Key(chapter.toString())),
|
autofocus: true,
|
||||||
|
onKeyEvent: onKeyEvent,
|
||||||
|
child: _ReaderScaffold(
|
||||||
|
child: _ReaderGestureDetector(
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
@@ -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:
|
||||||
|
@@ -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");
|
||||||
|
@@ -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:
|
||||||
|
@@ -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:
|
||||||
|
Reference in New Issue
Block a user