From 4b4e1fd0fd05f8d0b16803af229e279b2ee8485f Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:09:30 -0700 Subject: [PATCH 1/2] Improve photo loading and fullscreen swiping --- .../message/attachment/image_viewer.dart | 23 +++++++++++++++---- .../fullscreen_media/fullscreen_holder.dart | 9 +++++++- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer.dart b/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer.dart index bb40c5b64b..21994985e8 100644 --- a/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer.dart +++ b/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:math'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; -import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/models.dart'; import 'package:bluebubbles/services/services.dart'; import 'package:flutter/cupertino.dart'; @@ -55,11 +54,12 @@ class _ImageViewerState extends OptimizedState with AutomaticKeepAl final completer = Completer(); controller!.queueImage(Tuple4(attachment, file, context, completer)); final newData = await completer.future; - if (newData.isEmpty) return; + if (!mounted || newData.isEmpty) return; setState(() { data = newData; }); } else { + if (!mounted) return; setState(() { data = tmpData; }); @@ -78,13 +78,28 @@ class _ImageViewerState extends OptimizedState with AutomaticKeepAl height: min((attachment.height?.toDouble() ?? ns.width(context) * 0.5 / attachment.aspectRatio), ns.width(context) * 0.5 / attachment.aspectRatio), ); } + final maximumDisplayWidth = ns.width(context) * 0.5; + final sourceWidth = attachment.width?.toDouble(); + final sourceHeight = attachment.height?.toDouble(); + final displayWidth = min( + sourceWidth != null && sourceWidth > 0 ? sourceWidth : maximumDisplayWidth, + maximumDisplayWidth, + ); + final fallbackHeight = maximumDisplayWidth / + (attachment.aspectRatio.isFinite && attachment.aspectRatio > 0 ? attachment.aspectRatio : 1); + final displayHeight = min( + sourceHeight != null && sourceHeight > 0 ? sourceHeight : fallbackHeight, + fallbackHeight, + ); + final cacheWidth = (displayWidth * Get.pixelRatio / 2).round().clamp(1, 1024).toInt(); + final cacheHeight = (displayHeight * Get.pixelRatio / 2).round().clamp(1, 1024).toInt(); return Image.memory( data!, // prevents the image widget from "refreshing" when the provider changes gaplessPlayback: true, filterQuality: FilterQuality.none, - cacheWidth: (min((attachment.width ?? 0), ns.width(context) * 0.5) * Get.pixelRatio / 2).round().abs().nonZero, - cacheHeight: (min((attachment.height ?? 0), ns.width(context) * 0.5 / attachment.aspectRatio) * Get.pixelRatio / 2).round().abs().nonZero, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, fit: BoxFit.cover, frameBuilder: (context, w, frame, wasSyncLoaded) { return AnimatedCrossFade( diff --git a/lib/app/layouts/fullscreen_media/fullscreen_holder.dart b/lib/app/layouts/fullscreen_media/fullscreen_holder.dart index dec44c1892..f02e7975ad 100644 --- a/lib/app/layouts/fullscreen_media/fullscreen_holder.dart +++ b/lib/app/layouts/fullscreen_media/fullscreen_holder.dart @@ -14,6 +14,7 @@ import "package:flutter/material.dart"; import 'package:flutter/services.dart'; import 'package:gesture_x_detector/gesture_x_detector.dart'; import 'package:get/get.dart'; +import 'package:photo_view/photo_view.dart' show PhotoViewGestureDetectorScope; class FullscreenMediaHolder extends StatefulWidget { FullscreenMediaHolder({ @@ -151,7 +152,12 @@ class FullscreenMediaHolderState extends OptimizedState { } return KeyEventResult.ignored; }, - child: PageView.builder( + child: PhotoViewGestureDetectorScope( + // Lets a contained image yield horizontal drags to the + // PageView. A zoomed image keeps its own pan gesture, and + // FullscreenImage disables this PageView while zoomed. + axis: Axis.horizontal, + child: PageView.builder( physics: physics ?? (attachments.length == 1 ? const NeverScrollableScrollPhysics() @@ -302,6 +308,7 @@ class FullscreenMediaHolderState extends OptimizedState { ); } }, + ), ), ), ), From 736dce5eed236820b791726feb03137f728bfe5d Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:27:13 -0700 Subject: [PATCH 2/2] Add deterministic image decode sizing coverage --- .../message/attachment/image_viewer.dart | 25 ++++------ .../image_viewer_decode_dimensions.dart | 47 +++++++++++++++++++ .../image_viewer_decode_dimensions_test.dart | 43 +++++++++++++++++ 3 files changed, 99 insertions(+), 16 deletions(-) create mode 100644 lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer_decode_dimensions.dart create mode 100644 test/app/layouts/conversation_view/widgets/message/attachment/image_viewer_decode_dimensions_test.dart diff --git a/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer.dart b/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer.dart index 21994985e8..c56f22cae3 100644 --- a/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer.dart +++ b/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; +import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/attachment/image_viewer_decode_dimensions.dart'; import 'package:bluebubbles/database/models.dart'; import 'package:bluebubbles/services/services.dart'; import 'package:flutter/cupertino.dart'; @@ -78,28 +79,20 @@ class _ImageViewerState extends OptimizedState with AutomaticKeepAl height: min((attachment.height?.toDouble() ?? ns.width(context) * 0.5 / attachment.aspectRatio), ns.width(context) * 0.5 / attachment.aspectRatio), ); } - final maximumDisplayWidth = ns.width(context) * 0.5; - final sourceWidth = attachment.width?.toDouble(); - final sourceHeight = attachment.height?.toDouble(); - final displayWidth = min( - sourceWidth != null && sourceWidth > 0 ? sourceWidth : maximumDisplayWidth, - maximumDisplayWidth, + final decodeDimensions = calculateImageViewerDecodeDimensions( + maximumDisplayWidth: ns.width(context) * 0.5, + pixelRatio: Get.pixelRatio, + sourceWidth: attachment.width, + sourceHeight: attachment.height, + aspectRatio: attachment.aspectRatio, ); - final fallbackHeight = maximumDisplayWidth / - (attachment.aspectRatio.isFinite && attachment.aspectRatio > 0 ? attachment.aspectRatio : 1); - final displayHeight = min( - sourceHeight != null && sourceHeight > 0 ? sourceHeight : fallbackHeight, - fallbackHeight, - ); - final cacheWidth = (displayWidth * Get.pixelRatio / 2).round().clamp(1, 1024).toInt(); - final cacheHeight = (displayHeight * Get.pixelRatio / 2).round().clamp(1, 1024).toInt(); return Image.memory( data!, // prevents the image widget from "refreshing" when the provider changes gaplessPlayback: true, filterQuality: FilterQuality.none, - cacheWidth: cacheWidth, - cacheHeight: cacheHeight, + cacheWidth: decodeDimensions.width, + cacheHeight: decodeDimensions.height, fit: BoxFit.cover, frameBuilder: (context, w, frame, wasSyncLoaded) { return AnimatedCrossFade( diff --git a/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer_decode_dimensions.dart b/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer_decode_dimensions.dart new file mode 100644 index 0000000000..e113493868 --- /dev/null +++ b/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer_decode_dimensions.dart @@ -0,0 +1,47 @@ +import 'dart:math'; + +class ImageViewerDecodeDimensions { + const ImageViewerDecodeDimensions( + {required this.width, required this.height}); + + final int width; + final int height; +} + +ImageViewerDecodeDimensions calculateImageViewerDecodeDimensions({ + required double maximumDisplayWidth, + required double pixelRatio, + required int? sourceWidth, + required int? sourceHeight, + required double aspectRatio, +}) { + final safeMaximumDisplayWidth = + maximumDisplayWidth.isFinite && maximumDisplayWidth > 0 + ? maximumDisplayWidth + : 1.0; + final safePixelRatio = + pixelRatio.isFinite && pixelRatio > 0 ? pixelRatio : 1.0; + final safeAspectRatio = + aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : 1.0; + final displayWidth = min( + sourceWidth != null && sourceWidth > 0 + ? sourceWidth.toDouble() + : safeMaximumDisplayWidth, + safeMaximumDisplayWidth, + ); + final fallbackHeight = safeMaximumDisplayWidth / safeAspectRatio; + final displayHeight = min( + sourceHeight != null && sourceHeight > 0 + ? sourceHeight.toDouble() + : fallbackHeight, + fallbackHeight, + ); + + int cacheDimension(double displayDimension) => + (displayDimension * safePixelRatio / 2).round().clamp(1, 1024).toInt(); + + return ImageViewerDecodeDimensions( + width: cacheDimension(displayWidth), + height: cacheDimension(displayHeight), + ); +} diff --git a/test/app/layouts/conversation_view/widgets/message/attachment/image_viewer_decode_dimensions_test.dart b/test/app/layouts/conversation_view/widgets/message/attachment/image_viewer_decode_dimensions_test.dart new file mode 100644 index 0000000000..cdbf774648 --- /dev/null +++ b/test/app/layouts/conversation_view/widgets/message/attachment/image_viewer_decode_dimensions_test.dart @@ -0,0 +1,43 @@ +import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/attachment/image_viewer_decode_dimensions.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('bounds decoded dimensions to the displayed image size', () { + final dimensions = calculateImageViewerDecodeDimensions( + maximumDisplayWidth: 200, + pixelRatio: 3, + sourceWidth: 4000, + sourceHeight: 3000, + aspectRatio: 4 / 3, + ); + + expect(dimensions.width, 300); + expect(dimensions.height, 225); + }); + + test('uses safe positive fallbacks for missing or invalid metadata', () { + final dimensions = calculateImageViewerDecodeDimensions( + maximumDisplayWidth: double.nan, + pixelRatio: double.infinity, + sourceWidth: 0, + sourceHeight: null, + aspectRatio: 0, + ); + + expect(dimensions.width, 1); + expect(dimensions.height, 1); + }); + + test('caps large device-scaled decode dimensions', () { + final dimensions = calculateImageViewerDecodeDimensions( + maximumDisplayWidth: 4000, + pixelRatio: 4, + sourceWidth: 4000, + sourceHeight: 4000, + aspectRatio: 1, + ); + + expect(dimensions.width, 1024); + expect(dimensions.height, 1024); + }); +}