2022-12-18 12:52:57 +01:00
|
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
2021-12-10 05:53:22 +01:00
|
|
|
import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart';
|
2022-01-22 12:57:58 +01:00
|
|
|
import 'package:flutter/gestures.dart';
|
2021-05-02 16:18:26 +02:00
|
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
import 'package:logging/logging.dart';
|
|
|
|
import 'package:nc_photos/account.dart';
|
|
|
|
import 'package:nc_photos/api/api_util.dart' as api_util;
|
2021-09-16 12:10:50 +02:00
|
|
|
import 'package:nc_photos/cache_manager_util.dart';
|
2022-10-15 16:29:18 +02:00
|
|
|
import 'package:nc_photos/entity/file_descriptor.dart';
|
2022-05-06 07:23:04 +02:00
|
|
|
import 'package:nc_photos/entity/local_file.dart';
|
2022-12-18 12:52:57 +01:00
|
|
|
import 'package:nc_photos/flutter_util.dart' as flutter_util;
|
2021-05-02 16:18:26 +02:00
|
|
|
import 'package:nc_photos/k.dart' as k;
|
2022-05-06 07:23:04 +02:00
|
|
|
import 'package:nc_photos/mobile/android/content_uri_image_provider.dart';
|
2023-02-23 15:49:17 +01:00
|
|
|
import 'package:nc_photos/np_api_util.dart';
|
2021-05-02 16:18:26 +02:00
|
|
|
import 'package:nc_photos/widget/cached_network_image_mod.dart' as mod;
|
2022-12-18 12:52:57 +01:00
|
|
|
import 'package:nc_photos/widget/network_thumbnail.dart';
|
2022-12-16 16:01:04 +01:00
|
|
|
import 'package:np_codegen/np_codegen.dart';
|
|
|
|
|
|
|
|
part 'image_viewer.g.dart';
|
2021-05-02 16:18:26 +02:00
|
|
|
|
2022-05-06 07:23:04 +02:00
|
|
|
class LocalImageViewer extends StatefulWidget {
|
|
|
|
const LocalImageViewer({
|
|
|
|
Key? key,
|
|
|
|
required this.file,
|
|
|
|
required this.canZoom,
|
|
|
|
this.onLoaded,
|
|
|
|
this.onHeightChanged,
|
|
|
|
this.onZoomStarted,
|
|
|
|
this.onZoomEnded,
|
|
|
|
}) : super(key: key);
|
|
|
|
|
|
|
|
@override
|
|
|
|
createState() => _LocalImageViewerState();
|
|
|
|
|
|
|
|
final LocalFile file;
|
|
|
|
final bool canZoom;
|
|
|
|
final VoidCallback? onLoaded;
|
|
|
|
final ValueChanged<double>? onHeightChanged;
|
|
|
|
final VoidCallback? onZoomStarted;
|
|
|
|
final VoidCallback? onZoomEnded;
|
|
|
|
}
|
|
|
|
|
2022-12-16 16:01:04 +01:00
|
|
|
@npLog
|
2022-05-06 07:23:04 +02:00
|
|
|
class _LocalImageViewerState extends State<LocalImageViewer> {
|
|
|
|
@override
|
|
|
|
build(BuildContext context) {
|
|
|
|
final ImageProvider provider;
|
|
|
|
if (widget.file is LocalUriFile) {
|
|
|
|
provider = ContentUriImage((widget.file as LocalUriFile).uri);
|
|
|
|
} else {
|
|
|
|
throw ArgumentError("Invalid file");
|
|
|
|
}
|
|
|
|
|
|
|
|
return _ImageViewer(
|
|
|
|
canZoom: widget.canZoom,
|
|
|
|
onHeightChanged: widget.onHeightChanged,
|
|
|
|
onZoomStarted: widget.onZoomStarted,
|
|
|
|
onZoomEnded: widget.onZoomEnded,
|
|
|
|
child: Image(
|
|
|
|
image: provider,
|
|
|
|
fit: BoxFit.contain,
|
|
|
|
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
2022-06-20 13:49:58 +02:00
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
2022-05-06 07:23:04 +02:00
|
|
|
_onItemLoaded();
|
|
|
|
});
|
|
|
|
return child;
|
|
|
|
},
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _onItemLoaded() {
|
|
|
|
if (!_isLoaded) {
|
|
|
|
_log.info("[_onItemLoaded] ${widget.file.logTag}");
|
|
|
|
_isLoaded = true;
|
|
|
|
widget.onLoaded?.call();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var _isLoaded = false;
|
|
|
|
}
|
|
|
|
|
2022-04-21 21:13:31 +02:00
|
|
|
class RemoteImageViewer extends StatefulWidget {
|
|
|
|
const RemoteImageViewer({
|
2021-09-15 08:58:06 +02:00
|
|
|
Key? key,
|
2021-07-23 22:05:57 +02:00
|
|
|
required this.account,
|
|
|
|
required this.file,
|
|
|
|
required this.canZoom,
|
2021-05-02 16:18:26 +02:00
|
|
|
this.onLoaded,
|
|
|
|
this.onHeightChanged,
|
|
|
|
this.onZoomStarted,
|
|
|
|
this.onZoomEnded,
|
2021-09-15 08:58:06 +02:00
|
|
|
}) : super(key: key);
|
2021-05-02 16:18:26 +02:00
|
|
|
|
|
|
|
@override
|
2022-04-21 21:13:31 +02:00
|
|
|
createState() => _RemoteImageViewerState();
|
2021-05-02 16:18:26 +02:00
|
|
|
|
2022-10-15 16:29:18 +02:00
|
|
|
static void preloadImage(Account account, FileDescriptor file) {
|
2021-09-16 12:10:50 +02:00
|
|
|
LargeImageCacheManager.inst.getFileStream(
|
2021-05-02 16:18:26 +02:00
|
|
|
_getImageUrl(account, file),
|
|
|
|
headers: {
|
2023-02-23 15:49:17 +01:00
|
|
|
"Authorization": AuthUtil.fromAccount(account).toHeaderValue(),
|
2021-05-02 16:18:26 +02:00
|
|
|
},
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
final Account account;
|
2022-10-15 16:29:18 +02:00
|
|
|
final FileDescriptor file;
|
2021-05-02 16:18:26 +02:00
|
|
|
final bool canZoom;
|
2021-07-23 22:05:57 +02:00
|
|
|
final VoidCallback? onLoaded;
|
|
|
|
final ValueChanged<double>? onHeightChanged;
|
|
|
|
final VoidCallback? onZoomStarted;
|
|
|
|
final VoidCallback? onZoomEnded;
|
2021-05-02 16:18:26 +02:00
|
|
|
}
|
|
|
|
|
2022-12-16 16:01:04 +01:00
|
|
|
@npLog
|
2022-04-21 21:13:31 +02:00
|
|
|
class _RemoteImageViewerState extends State<RemoteImageViewer> {
|
|
|
|
@override
|
2022-12-18 12:52:57 +01:00
|
|
|
void initState() {
|
|
|
|
super.initState();
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
if (mounted) {
|
|
|
|
// needed to get rid of the large image blinking during Hero animation
|
|
|
|
setState(() {});
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return _ImageViewer(
|
|
|
|
canZoom: widget.canZoom,
|
|
|
|
onHeightChanged: widget.onHeightChanged,
|
|
|
|
onZoomStarted: widget.onZoomStarted,
|
|
|
|
onZoomEnded: widget.onZoomEnded,
|
|
|
|
child: Stack(
|
|
|
|
fit: StackFit.passthrough,
|
|
|
|
children: [
|
|
|
|
Hero(
|
|
|
|
tag: flutter_util.getImageHeroTag(widget.file),
|
|
|
|
flightShuttleBuilder: (flightContext, animation, flightDirection,
|
|
|
|
fromHeroContext, toHeroContext) {
|
|
|
|
_isHeroDone = false;
|
|
|
|
animation.addStatusListener(_animationListener);
|
|
|
|
return flutter_util.defaultHeroFlightShuttleBuilder(
|
|
|
|
flightContext,
|
|
|
|
animation,
|
|
|
|
flightDirection,
|
|
|
|
fromHeroContext,
|
|
|
|
toHeroContext,
|
|
|
|
);
|
|
|
|
},
|
|
|
|
child: CachedNetworkImage(
|
|
|
|
fit: BoxFit.contain,
|
|
|
|
cacheManager: ThumbnailCacheManager.inst,
|
|
|
|
imageUrl: NetworkRectThumbnail.imageUrlForFile(
|
|
|
|
widget.account, widget.file),
|
|
|
|
httpHeaders: {
|
|
|
|
"Authorization":
|
2023-02-23 15:49:17 +01:00
|
|
|
AuthUtil.fromAccount(widget.account).toHeaderValue(),
|
2022-12-18 12:52:57 +01:00
|
|
|
},
|
|
|
|
fadeInDuration: const Duration(),
|
|
|
|
filterQuality: FilterQuality.high,
|
|
|
|
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
if (_isHeroDone)
|
|
|
|
mod.CachedNetworkImage(
|
|
|
|
cacheManager: LargeImageCacheManager.inst,
|
|
|
|
imageUrl: _getImageUrl(widget.account, widget.file),
|
|
|
|
httpHeaders: {
|
|
|
|
"Authorization":
|
2023-02-23 15:49:17 +01:00
|
|
|
AuthUtil.fromAccount(widget.account).toHeaderValue(),
|
2022-12-18 12:52:57 +01:00
|
|
|
},
|
|
|
|
fit: BoxFit.contain,
|
|
|
|
fadeInDuration: const Duration(),
|
|
|
|
filterQuality: FilterQuality.high,
|
|
|
|
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
|
|
|
|
imageBuilder: (context, child, imageProvider) {
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
_onItemLoaded();
|
|
|
|
});
|
|
|
|
const SizeChangedLayoutNotification().dispatch(context);
|
|
|
|
return child;
|
|
|
|
},
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
2022-04-21 21:13:31 +02:00
|
|
|
|
|
|
|
void _onItemLoaded() {
|
|
|
|
if (!_isLoaded) {
|
|
|
|
_log.info("[_onItemLoaded]");
|
|
|
|
_isLoaded = true;
|
|
|
|
widget.onLoaded?.call();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-12-18 12:52:57 +01:00
|
|
|
void _animationListener(AnimationStatus status) {
|
|
|
|
if (status == AnimationStatus.completed) {
|
|
|
|
_isHeroDone = true;
|
|
|
|
if (mounted) {
|
|
|
|
setState(() {});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-04-21 21:13:31 +02:00
|
|
|
var _isLoaded = false;
|
2022-12-18 12:52:57 +01:00
|
|
|
// initially set to true such that the large image will show when hero didn't
|
|
|
|
// run (i.e., when swiping in viewer)
|
|
|
|
var _isHeroDone = true;
|
2022-04-21 21:13:31 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
class _ImageViewer extends StatefulWidget {
|
|
|
|
const _ImageViewer({
|
|
|
|
Key? key,
|
|
|
|
required this.child,
|
|
|
|
required this.canZoom,
|
|
|
|
this.onHeightChanged,
|
|
|
|
this.onZoomStarted,
|
|
|
|
this.onZoomEnded,
|
|
|
|
}) : super(key: key);
|
|
|
|
|
|
|
|
@override
|
|
|
|
createState() => _ImageViewerState();
|
|
|
|
|
|
|
|
final Widget child;
|
|
|
|
final bool canZoom;
|
|
|
|
final ValueChanged<double>? onHeightChanged;
|
|
|
|
final VoidCallback? onZoomStarted;
|
|
|
|
final VoidCallback? onZoomEnded;
|
|
|
|
}
|
|
|
|
|
2022-12-16 16:01:04 +01:00
|
|
|
@npLog
|
2022-04-21 21:13:31 +02:00
|
|
|
class _ImageViewerState extends State<_ImageViewer>
|
2021-05-02 16:18:26 +02:00
|
|
|
with TickerProviderStateMixin {
|
|
|
|
@override
|
|
|
|
build(BuildContext context) {
|
|
|
|
final content = InteractiveViewer(
|
|
|
|
minScale: 1.0,
|
2021-11-26 11:10:32 +01:00
|
|
|
maxScale: 3.5,
|
2021-05-02 16:18:26 +02:00
|
|
|
transformationController: _transformationController,
|
2021-11-26 11:10:18 +01:00
|
|
|
panEnabled: widget.canZoom && _isZoomed,
|
2021-05-02 16:18:26 +02:00
|
|
|
scaleEnabled: widget.canZoom,
|
|
|
|
// allow the image to be zoomed to fill the whole screen
|
|
|
|
child: Container(
|
|
|
|
width: MediaQuery.of(context).size.width,
|
|
|
|
height: MediaQuery.of(context).size.height,
|
|
|
|
alignment: Alignment.center,
|
|
|
|
child: NotificationListener<SizeChangedLayoutNotification>(
|
|
|
|
onNotification: (_) {
|
2022-06-20 13:49:58 +02:00
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
2021-05-02 16:18:26 +02:00
|
|
|
if (_key.currentContext != null) {
|
2021-07-23 22:05:57 +02:00
|
|
|
widget.onHeightChanged?.call(_key.currentContext!.size!.height);
|
2021-05-02 16:18:26 +02:00
|
|
|
}
|
|
|
|
});
|
|
|
|
return false;
|
|
|
|
},
|
|
|
|
child: SizeChangedLayoutNotifier(
|
2022-04-21 21:13:31 +02:00
|
|
|
key: _key,
|
2022-10-16 06:20:28 +02:00
|
|
|
child: IntrinsicHeight(child: widget.child),
|
2021-05-02 16:18:26 +02:00
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
if (widget.canZoom) {
|
|
|
|
return Listener(
|
|
|
|
onPointerDown: (_) {
|
|
|
|
++_finger;
|
|
|
|
if (_finger >= 2) {
|
|
|
|
_setIsZooming(true);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
onPointerUp: (event) {
|
|
|
|
--_finger;
|
|
|
|
if (_finger < 2) {
|
|
|
|
_setIsZooming(false);
|
|
|
|
}
|
|
|
|
_prevFingerPosition = event.position;
|
|
|
|
},
|
2021-12-11 06:38:51 +01:00
|
|
|
onPointerCancel: (event) {
|
|
|
|
--_finger;
|
|
|
|
if (_finger < 2) {
|
|
|
|
_setIsZooming(false);
|
|
|
|
}
|
2022-01-22 12:57:58 +01:00
|
|
|
},
|
|
|
|
onPointerSignal: (event) {
|
|
|
|
if (event is PointerScrollEvent &&
|
|
|
|
event.kind == PointerDeviceKind.mouse) {
|
|
|
|
if (event.scrollDelta.dy < 0) {
|
|
|
|
// scroll up
|
|
|
|
_setIsZooming(true);
|
|
|
|
} else if (event.scrollDelta.dy > 0) {
|
|
|
|
// scroll down
|
|
|
|
_setIsZooming(false);
|
|
|
|
}
|
|
|
|
}
|
2021-12-11 06:38:51 +01:00
|
|
|
},
|
2021-05-02 16:18:26 +02:00
|
|
|
child: GestureDetector(
|
|
|
|
onDoubleTap: () {
|
|
|
|
if (_isZoomed) {
|
|
|
|
// restore transformation
|
|
|
|
_autoZoomOut();
|
|
|
|
} else {
|
|
|
|
_autoZoomIn();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
child: content,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
return content;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
dispose() {
|
|
|
|
super.dispose();
|
|
|
|
_transformationController.dispose();
|
|
|
|
}
|
|
|
|
|
|
|
|
void _setIsZooming(bool flag) {
|
|
|
|
_isZooming = flag;
|
|
|
|
final next = _isZoomed;
|
|
|
|
if (next != _wasZoomed) {
|
|
|
|
_wasZoomed = next;
|
|
|
|
_log.info("[_setIsZooming] Is zoomed: $next");
|
|
|
|
if (next) {
|
|
|
|
widget.onZoomStarted?.call();
|
|
|
|
} else {
|
|
|
|
widget.onZoomEnded?.call();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
bool get _isZoomed =>
|
|
|
|
_isZooming || _transformationController.value.getMaxScaleOnAxis() != 1.0;
|
|
|
|
|
|
|
|
/// Called when double tapping the image to zoom in to the default level
|
|
|
|
void _autoZoomIn() {
|
|
|
|
final animController =
|
|
|
|
AnimationController(duration: k.animationDurationShort, vsync: this);
|
|
|
|
final originX = -_prevFingerPosition.dx / 2;
|
|
|
|
final originY = -_prevFingerPosition.dy / 2;
|
|
|
|
final anim = Matrix4Tween(
|
|
|
|
begin: Matrix4.identity(),
|
|
|
|
end: Matrix4.identity()
|
|
|
|
..scale(2.0)
|
|
|
|
..translate(originX, originY))
|
|
|
|
.animate(animController);
|
|
|
|
animController
|
|
|
|
..addListener(() {
|
|
|
|
_transformationController.value = anim.value;
|
|
|
|
})
|
|
|
|
..addStatusListener((status) {
|
|
|
|
if (status == AnimationStatus.completed) {
|
|
|
|
_setIsZooming(false);
|
|
|
|
}
|
|
|
|
})
|
|
|
|
..forward();
|
|
|
|
_setIsZooming(true);
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Called when double tapping the zoomed image to zoom out
|
|
|
|
void _autoZoomOut() {
|
|
|
|
final animController =
|
|
|
|
AnimationController(duration: k.animationDurationShort, vsync: this);
|
|
|
|
final anim = Matrix4Tween(
|
|
|
|
begin: _transformationController.value, end: Matrix4.identity())
|
|
|
|
.animate(animController);
|
|
|
|
animController
|
|
|
|
..addListener(() {
|
|
|
|
_transformationController.value = anim.value;
|
|
|
|
})
|
|
|
|
..addStatusListener((status) {
|
|
|
|
if (status == AnimationStatus.completed) {
|
|
|
|
_setIsZooming(false);
|
|
|
|
}
|
|
|
|
})
|
|
|
|
..forward();
|
|
|
|
_setIsZooming(true);
|
|
|
|
}
|
|
|
|
|
|
|
|
final _key = GlobalKey();
|
|
|
|
final _transformationController = TransformationController();
|
|
|
|
|
|
|
|
var _isZooming = false;
|
|
|
|
var _wasZoomed = false;
|
|
|
|
|
|
|
|
int _finger = 0;
|
2021-09-15 08:58:06 +02:00
|
|
|
var _prevFingerPosition = const Offset(0, 0);
|
2021-05-02 16:18:26 +02:00
|
|
|
}
|
|
|
|
|
2022-10-15 16:29:18 +02:00
|
|
|
String _getImageUrl(Account account, FileDescriptor file) {
|
|
|
|
if (file.fdMime == "image/gif") {
|
2021-06-22 07:24:37 +02:00
|
|
|
return api_util.getFileUrl(account, file);
|
|
|
|
} else {
|
|
|
|
return api_util.getFilePreviewUrl(
|
2021-05-02 16:18:26 +02:00
|
|
|
account,
|
|
|
|
file,
|
2021-09-16 12:25:08 +02:00
|
|
|
width: k.photoLargeSize,
|
|
|
|
height: k.photoLargeSize,
|
2022-12-18 07:35:54 +01:00
|
|
|
isKeepAspectRatio: true,
|
2021-05-02 16:18:26 +02:00
|
|
|
);
|
2021-06-22 07:24:37 +02:00
|
|
|
}
|
|
|
|
}
|