Files
tatuchat/lib/pages/image_viewer/image_viewer.dart
Welton Moura d65be8a1ef fix: back do android saia da sala em vez de fechar o viewer de imagem
O PopScope sozinho nao resolvia: GoRouterDelegate.popRoute() itera
_findCurrentNavigators(), que devolve [root, shell] e retorna
states.reversed, ou seja, o shell navigator e consultado primeiro. O
viewer e um showDialog no root navigator, entao o shell nunca ve o
PopEntry dele, popa a sala e retorna true antes de chegar no dialog.

BackButtonListener registra no BackButtonDispatcher raiz, que e
consultado antes do RouterDelegate.popRoute(), entao o viewer fecha
primeiro. Aplicado tambem no MxcImageViewer (avatar/sticker), que tinha
o mesmo defeito.
2026-09-27 17:30:37 -03:00

224 lines
6.6 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) => BackButtonListener(
// A [PopScope] alone is not enough here: go_router's `popRoute()` asks
// `GoRouterDelegate._findCurrentNavigators()`, which returns the shell
// navigator BEFORE the root one. The viewer is a dialog on the root
// navigator, so the shell navigator never sees its [PopEntry], pops the
// chat room instead and returns `true`, leaving the image on screen.
// A [BackButtonListener] is asked before the router gets the request at
// all, so the viewer gets to close itself first.
onBackButtonPressed: () async {
closeAction();
return true;
},
child: PopScope<Object?>(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
closeAction();
},
child: ImageViewerView(this),
),
);
}