// SPDX-FileCopyrightText: 2019-Present Christian Kußowski // SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat // // SPDX-License-Identifier: AGPL-3.0-or-later import 'package:tatchat/config/themes.dart'; import 'package:tatchat/pages/image_viewer/image_viewer_view.dart'; import 'package:tatchat/utils/platform_infos.dart'; import 'package:tatchat/utils/show_scaffold_dialog.dart'; import 'package:tatchat/widgets/share_scaffold_dialog.dart'; import 'package:flutter/services.dart'; import 'package:material_ui/material_ui.dart'; import 'package:matrix/matrix.dart'; import '../../utils/matrix_sdk_extensions/event_extension.dart'; class ImageViewer extends StatefulWidget { final Event event; final Timeline? timeline; final BuildContext outerContext; const ImageViewer( this.event, { required this.outerContext, this.timeline, super.key, }); @override ImageViewerController createState() => ImageViewerController(); } class ImageViewerController extends State { final FocusNode focusNode = FocusNode(); /// One [TransformationController] per image, so the zoom of one page never /// leaks into its neighbours. final Map _transformations = {}; int _pointerCount = 0; bool _isZoomed = false; /// True while an image is zoomed in, so panning moves the image instead of /// switching to the next one. bool get isZoomed => _isZoomed; /// The [PageView] must not compete for the gesture while pinching or while /// an image is zoomed, otherwise the swipe recognizer wins the gesture arena /// and pinch-to-zoom never happens. bool get canSwipeToNextImage => _pointerCount < 2 && !_isZoomed; TransformationController transformationControllerFor(Event event) => _transformations.putIfAbsent(event.eventId, () { final controller = TransformationController(); controller.addListener(_onTransformChanged); return controller; }); void onPointerDown() { if (!mounted) return; setState(() => _pointerCount++); } void onPointerUp() { if (!mounted || _pointerCount == 0) return; setState(() => _pointerCount--); } void _onTransformChanged() { if (!mounted) return; final zoomed = _transformations.values.any( (controller) => controller.value.getMaxScaleOnAxis() > 1.01, ); if (zoomed == _isZoomed) return; setState(() => _isZoomed = zoomed); } /// Resets the zoom of every image, e.g. after moving to another one. void resetZooms() { for (final controller in _transformations.values) { if (!controller.value.isIdentity()) { controller.value = Matrix4.identity(); } } } @override void initState() { super.initState(); allEvents = widget.timeline?.events .where( (event) => { MessageTypes.Image, MessageTypes.Sticker, if (PlatformInfos.supportsVideoPlayer) MessageTypes.Video, }.contains(event.messageType), ) .toList() .reversed .toList() ?? [widget.event]; var index = allEvents.indexWhere( (event) => event.eventId == widget.event.eventId, ); if (index < 0) index = 0; pageController = PageController(initialPage: index); } late final PageController pageController; late final List allEvents; void onKeyEvent(KeyEvent event) { switch (event.logicalKey) { case LogicalKeyboardKey.arrowUp: if (canGoBack) prevImage(); break; case LogicalKeyboardKey.arrowDown: if (canGoNext) nextImage(); break; } } Future prevImage() async { if (!canGoBack) return; resetZooms(); await pageController.previousPage( duration: FluffyThemes.animationDuration, curve: FluffyThemes.animationCurve, ); if (!mounted) return; setState(() {}); } Future nextImage() async { if (!canGoNext) return; resetZooms(); await pageController.nextPage( duration: FluffyThemes.animationDuration, curve: FluffyThemes.animationCurve, ); if (!mounted) return; setState(() {}); } int get _index => pageController.page?.toInt() ?? 0; Event get currentEvent => allEvents[_index]; bool get canGoNext => _index < allEvents.length - 1; bool get canGoBack => _index > 0; /// Forward this image to another room. void forwardAction() => showScaffoldDialog( context: context, builder: (context) => ShareScaffoldDialog(items: [ContentShareItem(currentEvent.content)]), ); /// Save this file with a system call. void saveFileAction(BuildContext context) => currentEvent.saveFile(context); /// Save this file with a system call. void shareFileAction(BuildContext context) => currentEvent.shareFile(context); static const maxScaleFactor = 1.5; /// Closes the viewer. /// /// Always targets the root navigator, because the viewer is opened with /// `showDialog` on the root navigator while the chat room lives in a nested /// navigator of the go_router shell. Using a relative navigator here would /// either pop nothing or pop the wrong route. void closeAction() => Navigator.of(context, rootNavigator: true).pop(); /// Go back if user swiped it away void onInteractionEnds(ScaleEndDetails endDetails) { if (PlatformInfos.usesTouchscreen == false) { if (isZoomed) return; if (endDetails.velocity.pixelsPerSecond.dy > MediaQuery.sizeOf(context).height * maxScaleFactor) { closeAction(); } } } @override void dispose() { for (final controller in _transformations.values) { controller.removeListener(_onTransformChanged); controller.dispose(); } _transformations.clear(); focusNode.dispose(); pageController.dispose(); super.dispose(); } @override Widget build(BuildContext context) => PopScope( // Claim the system back button (and predictive back gesture) for the // viewer. Without this, the request can reach go_router instead, which // pops the chat room out of the shell and leaves the image on screen. canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; closeAction(); }, child: ImageViewerView(this), ); }