214 lines
6.2 KiB
Dart
214 lines
6.2 KiB
Dart
// 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<ImageViewer> {
|
|
final FocusNode focusNode = FocusNode();
|
|
|
|
/// One [TransformationController] per image, so the zoom of one page never
|
|
/// leaks into its neighbours.
|
|
final Map<String, TransformationController> _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<Event> allEvents;
|
|
|
|
void onKeyEvent(KeyEvent event) {
|
|
switch (event.logicalKey) {
|
|
case LogicalKeyboardKey.arrowUp:
|
|
if (canGoBack) prevImage();
|
|
break;
|
|
case LogicalKeyboardKey.arrowDown:
|
|
if (canGoNext) nextImage();
|
|
break;
|
|
}
|
|
}
|
|
|
|
Future<void> prevImage() async {
|
|
if (!canGoBack) return;
|
|
resetZooms();
|
|
await pageController.previousPage(
|
|
duration: FluffyThemes.animationDuration,
|
|
curve: FluffyThemes.animationCurve,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {});
|
|
}
|
|
|
|
Future<void> 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<Object?>(
|
|
// 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),
|
|
);
|
|
}
|