Add mobile message image galleries (#3312)

## What
- group uploaded photos into full-width message carousels
- add a fullscreen viewer with pinch zoom, double-tap reset, swipe-down
dismissal, a centered filmstrip, and image actions
- preload nearby display-sized images for smoother swiping and keep each
upload as its own avatar-backed message

## Validation
- `just mobile-check`
- `flutter test test/features/channels/message_content_test.dart`
- iOS 26.5 simulator gesture pass

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-07-28 16:48:35 +01:00
committed by GitHub
parent 4a977c588a
commit 7dfea2634f
20 changed files with 2039 additions and 238 deletions

View File

@@ -1,6 +1,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="28"
tools:replace="android:maxSdkVersion" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<application
android:label="@string/app_name"
android:name="${applicationName}"

View File

@@ -97,6 +97,9 @@ class MainActivity : FlutterActivity() {
TRANSCODE_VIDEO_TO_MP4_METHOD -> {
handleTranscodeVideoToMp4(call.arguments, result)
}
REQUIRES_LEGACY_MEDIA_STORAGE_PERMISSION_METHOD -> {
result.success(Build.VERSION.SDK_INT <= Build.VERSION_CODES.P)
}
else -> result.notImplemented()
}
}
@@ -284,5 +287,7 @@ class MainActivity : FlutterActivity() {
private const val SANITIZE_IMAGE_FOR_UPLOAD_METHOD = "sanitizeImageForUpload"
private const val TRANSCODE_IMAGE_TO_JPEG_METHOD = "transcodeImageToJpeg"
private const val TRANSCODE_VIDEO_TO_MP4_METHOD = "transcodeVideoToMp4"
private const val REQUIRES_LEGACY_MEDIA_STORAGE_PERMISSION_METHOD =
"requiresLegacyMediaStoragePermission"
}
}

View File

@@ -22,6 +22,11 @@ PODS:
- Flutter
- package_info_plus (0.4.5):
- Flutter
- photo_manager (3.11.0):
- Flutter
- FlutterMacOS
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
@@ -43,6 +48,8 @@ DEPENDENCIES:
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
- open_filex (from `.symlinks/plugins/open_filex/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/darwin`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
@@ -70,6 +77,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/open_filex/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
photo_manager:
:path: ".symlinks/plugins/photo_manager/darwin"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
url_launcher_ios:
@@ -89,6 +100,8 @@ SPEC CHECKSUMS:
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
photo_manager: 6ab48c2ce7ec21aa06d59e6cc049f0b6d9ba7f94
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a

View File

@@ -47,6 +47,8 @@
<string>Buzz needs camera access so you can take photos to attach to messages and scan QR codes for device pairing.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Buzz needs photo library access so you can attach images to messages.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Buzz needs permission to save images to your photo library.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>

View File

@@ -29,6 +29,10 @@ class _MessageBubble extends ConsumerWidget {
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
ref.read(userCacheProvider.notifier).get(pk);
final displayName = profile?.label ?? shortPubkey(message.pubkey);
final canManageMessage =
currentPubkey?.toLowerCase() == pk ||
(profile?.ownerPubkey != null &&
profile?.ownerPubkey == currentPubkey?.toLowerCase());
// Build mention names map from event p-tags.
final userCache = ref.watch(userCacheProvider);
@@ -55,10 +59,7 @@ class _MessageBubble extends ConsumerWidget {
ref: ref,
message: message,
channelId: currentChannelId,
canManageMessage:
currentPubkey?.toLowerCase() == pk ||
(profile?.ownerPubkey != null &&
profile?.ownerPubkey == currentPubkey?.toLowerCase()),
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
@@ -124,6 +125,38 @@ class _MessageBubble extends ConsumerWidget {
baseStyle: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurface,
),
mediaCarouselTrailingOverflow: Grid.gutter,
onMediaReply: allMessages == null
? null
: () {
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: currentChannelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
onMediaMore: (viewerContext, imageUrl) =>
showImageActions(
context: viewerContext,
ref: ref,
message: message,
channelId: currentChannelId,
imageUrl: imageUrl,
canManageMessage: canManageMessage,
onDeleted: () {
if (viewerContext.mounted) {
Navigator.of(viewerContext).maybePop();
}
},
),
onChannelTap: (channelId) {
openChannelLink(
context: context,

View File

@@ -240,7 +240,8 @@ class _MessageList extends HookConsumerWidget {
final showAuthor =
!message.isSystem &&
(prevMessage == null ||
(message.hasAttachments ||
prevMessage == null ||
prevMessage.isSystem ||
showDayDivider ||
prevMessage.pubkey.toLowerCase() !=

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
/// Keeps image-viewer shared-element motion consistent at every source.
class MediaViewerHero extends StatelessWidget {
/// The identity shared by the inline image and full-screen image.
final Object tag;
/// The image rendered during and after the shared-element transition.
final Widget child;
/// Creates an image-viewer shared element.
const MediaViewerHero({super.key, required this.tag, required this.child});
@override
Widget build(BuildContext context) {
return Hero(
tag: tag,
createRectTween: (begin, end) => RectTween(begin: begin, end: end),
flightShuttleBuilder:
(
flightContext,
animation,
flightDirection,
fromHeroContext,
toHeroContext,
) {
final sourceHero = fromHeroContext.widget;
final destinationHero = toHeroContext.widget;
final sourceChild = sourceHero is Hero ? sourceHero.child : child;
final destinationChild = destinationHero is Hero
? destinationHero.child
: child;
return _MediaViewerHeroFlight(
animation: animation,
sourceChild: sourceChild,
destinationChild: destinationChild,
);
},
child: child,
);
}
}
class _MediaViewerHeroFlight extends StatelessWidget {
final Animation<double> animation;
final Widget sourceChild;
final Widget destinationChild;
const _MediaViewerHeroFlight({
required this.animation,
required this.sourceChild,
required this.destinationChild,
});
@override
Widget build(BuildContext context) {
final destinationOpacity = CurvedAnimation(
parent: animation,
curve: const Interval(0.18, 0.82, curve: Curves.easeInOutCubic),
);
return Stack(
fit: StackFit.expand,
children: [
FadeTransition(
opacity: ReverseAnimation(destinationOpacity),
child: sourceChild,
),
FadeTransition(opacity: destinationOpacity, child: destinationChild),
],
);
}
}

View File

@@ -1,34 +1,105 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:video_player/video_player.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import 'media_viewer_hero.dart';
const _imageViewerPushDuration = Duration(milliseconds: 280);
const _imageViewerPopDuration = Duration(milliseconds: 220);
const _imageViewerTransitionOffset = Offset(0, 0.08);
export 'media_viewer_hero.dart';
part 'media_viewer_page/image_controls.dart';
part 'media_viewer_page/route_transition.dart';
const _imageViewerPushDuration = Duration(milliseconds: 260);
const _imageViewerPopDuration = Duration(milliseconds: 170);
const _identityTransformEpsilon = 0.0001;
final List<double> _identityTransformStorage = List<double>.unmodifiable(
Matrix4.identity().storage,
);
/// Opens message-specific actions for the currently visible image.
typedef MediaViewerMoreAction =
void Function(BuildContext context, String imageUrl);
/// An image and its source Hero tag in a full-screen media gallery.
@immutable
class MediaViewerImage {
/// The image URL.
final String url;
/// The shared-element transition tag for the source thumbnail.
final Object heroTag;
/// The accessible image description.
final String? semanticLabel;
/// The logical decode width already cached by the source thumbnail.
final double? previewDecodeWidth;
/// The image's intrinsic width-to-height ratio, when provided by metadata.
final double? aspectRatio;
/// A display-sized provider that can be warmed before this page is shown.
final ImageProvider<Object>? preloadProvider;
/// Creates a media-viewer image.
const MediaViewerImage({
required this.url,
required this.heroTag,
this.semanticLabel,
this.previewDecodeWidth,
this.aspectRatio,
this.preloadProvider,
});
}
PageRoute<void> buildImageViewerRoute({
required String imageUrl,
required Object heroTag,
String? semanticLabel,
double? previewDecodeWidth,
double? aspectRatio,
List<MediaViewerImage>? galleryItems,
int initialIndex = 0,
VoidCallback? onReply,
MediaViewerMoreAction? onMore,
bool disableAnimations = false,
}) {
final images =
galleryItems ??
[
MediaViewerImage(
url: imageUrl,
heroTag: heroTag,
semanticLabel: semanticLabel,
previewDecodeWidth: previewDecodeWidth,
aspectRatio: aspectRatio,
),
];
final safeInitialIndex = initialIndex.clamp(0, images.length - 1).toInt();
return PageRouteBuilder<void>(
transitionDuration: _imageViewerPushDuration,
reverseTransitionDuration: _imageViewerPopDuration,
transitionDuration: disableAnimations
? Duration.zero
: _imageViewerPushDuration,
reverseTransitionDuration: disableAnimations
? Duration.zero
: _imageViewerPopDuration,
pageBuilder: (context, animation, secondaryAnimation) =>
MediaImageViewerPage(
imageUrl: imageUrl,
heroTag: heroTag,
semanticLabel: semanticLabel,
galleryItems: images,
initialIndex: safeInitialIndex,
onReply: onReply,
onMore: onMore,
),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
_MediaViewerRouteTransition(animation: animation, child: child),
@@ -40,12 +111,25 @@ void openImageViewer(
required String imageUrl,
required Object heroTag,
String? semanticLabel,
double? previewDecodeWidth,
double? aspectRatio,
List<MediaViewerImage>? galleryItems,
int initialIndex = 0,
VoidCallback? onReply,
MediaViewerMoreAction? onMore,
}) {
Navigator.of(context).push(
buildImageViewerRoute(
imageUrl: imageUrl,
heroTag: heroTag,
semanticLabel: semanticLabel,
previewDecodeWidth: previewDecodeWidth,
aspectRatio: aspectRatio,
galleryItems: galleryItems,
initialIndex: initialIndex,
onReply: onReply,
onMore: onMore,
disableAnimations: MediaQuery.disableAnimationsOf(context),
),
);
}
@@ -57,8 +141,12 @@ void openVideoViewer(
}) {
Navigator.of(context).push(
PageRouteBuilder<void>(
transitionDuration: _imageViewerPushDuration,
reverseTransitionDuration: _imageViewerPopDuration,
transitionDuration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: _imageViewerPushDuration,
reverseTransitionDuration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: _imageViewerPopDuration,
pageBuilder: (context, animation, secondaryAnimation) =>
MediaVideoViewerPage(videoUrl: videoUrl, posterUrl: posterUrl),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
@@ -67,189 +155,345 @@ void openVideoViewer(
);
}
class _MediaViewerRouteTransition extends StatelessWidget {
final Animation<double> animation;
final Widget child;
const _MediaViewerRouteTransition({
required this.animation,
required this.child,
});
@override
Widget build(BuildContext context) {
final fade = CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
);
final slide = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return FadeTransition(
opacity: fade,
child: SlideTransition(
position: Tween<Offset>(
begin: _imageViewerTransitionOffset,
end: Offset.zero,
).animate(slide),
child: child,
),
);
}
}
// StatefulWidget retained: imperative gesture/animation controllers with
// listener lifecycle don't map cleanly to hooks (allowed exception).
class MediaImageViewerPage extends StatefulWidget {
class MediaImageViewerPage extends HookConsumerWidget {
final String imageUrl;
final Object heroTag;
final String? semanticLabel;
final List<MediaViewerImage>? galleryItems;
final int initialIndex;
final VoidCallback? onReply;
final MediaViewerMoreAction? onMore;
const MediaImageViewerPage({
super.key,
required this.imageUrl,
required this.heroTag,
this.semanticLabel,
this.galleryItems,
this.initialIndex = 0,
this.onReply,
this.onMore,
});
@override
State<MediaImageViewerPage> createState() => _MediaImageViewerPageState();
}
class _MediaImageViewerPageState extends State<MediaImageViewerPage>
with SingleTickerProviderStateMixin {
late final TransformationController _transformationController;
late final AnimationController _snapBackController;
bool _isTransformed = false;
bool _disableHeroOnDismiss = false;
double _dragOffset = 0;
bool _isDragging = false;
static const _dismissThreshold = 100.0;
static const _dismissVelocity = 700.0;
static const _backgroundFadeDivisor = 300.0;
static const _filmstripScrubExtent = 44.0;
@override
void initState() {
super.initState();
_transformationController = TransformationController();
_transformationController.addListener(_handleTransformChanged);
_snapBackController = AnimationController(
vsync: this,
Widget build(BuildContext context, WidgetRef ref) {
final images = useMemoized(
() =>
galleryItems ??
[
MediaViewerImage(
url: imageUrl,
heroTag: heroTag,
semanticLabel: semanticLabel,
),
],
[galleryItems, imageUrl, heroTag, semanticLabel],
);
final safeInitialIndex = initialIndex.clamp(0, images.length - 1).toInt();
final currentIndex = useState(safeInitialIndex);
final pageController = usePageController(initialPage: safeInitialIndex);
final pagePosition = useState(safeInitialIndex.toDouble());
final transformationControllers = useMemoized(
() => [
for (var index = 0; index < images.length; index++)
TransformationController(),
],
[images],
);
final snapBackController = useAnimationController(
duration: const Duration(milliseconds: 200),
);
}
@override
void dispose() {
_transformationController.removeListener(_handleTransformChanged);
_transformationController.dispose();
_snapBackController.dispose();
super.dispose();
}
void _handleTransformChanged() {
final isTransformed = _hasImageTransform(_transformationController.value);
if (isTransformed == _isTransformed) {
return;
}
setState(() {
_isTransformed = isTransformed;
// If the user zooms in while dragging, cancel the drag.
if (_isTransformed && _isDragging) {
_isDragging = false;
_dragOffset = 0;
}
});
}
void _onInteractionStart(ScaleStartDetails details) {
if (!_isTransformed && details.pointerCount == 1) {
_isDragging = true;
}
}
void _onInteractionUpdate(ScaleUpdateDetails details) {
if (_isDragging && !_isTransformed) {
setState(() {
_dragOffset += details.focalPointDelta.dy;
});
}
}
void _onInteractionEnd(ScaleEndDetails details) {
if (!_isDragging) return;
_isDragging = false;
if (_dragOffset.abs() > _dismissThreshold) {
_dismiss();
} else {
_animateSnapBack();
}
}
void _animateSnapBack() {
final startOffset = _dragOffset;
final tween = Tween<double>(begin: startOffset, end: 0);
final curved = CurvedAnimation(
parent: _snapBackController,
curve: Curves.easeOut,
final zoomResetController = useAnimationController(
duration: const Duration(milliseconds: 180),
);
final zoomResetListener = useRef<VoidCallback?>(null);
final fullResolutionIndices = useState<Set<int>>(<int>{});
final isTransformed = useState(false);
final disableHeroOnDismiss = useState(false);
final dragOffset = useState(0.0);
final isDragging = useState(false);
void listener() {
setState(() {
_dragOffset = tween.evaluate(curved);
void handlePagePositionChanged() {
if (!pageController.hasClients) return;
final nextPosition = pageController.page;
if (nextPosition == null ||
(nextPosition - pagePosition.value).abs() < 0.0001) {
return;
}
pagePosition.value = nextPosition;
}
void handleTransformChanged(int index) {
if (index != currentIndex.value) return;
final nextIsTransformed = _hasImageTransform(
transformationControllers[index].value,
);
if (nextIsTransformed == isTransformed.value) {
return;
}
isTransformed.value = nextIsTransformed;
if (nextIsTransformed && isDragging.value) {
isDragging.value = false;
dragOffset.value = 0;
}
}
useEffect(() {
pageController.addListener(handlePagePositionChanged);
return () => pageController.removeListener(handlePagePositionChanged);
}, [pageController]);
useEffect(() {
final listeners = <VoidCallback>[];
for (var index = 0; index < transformationControllers.length; index++) {
final controllerIndex = index;
void listener() => handleTransformChanged(controllerIndex);
listeners.add(listener);
transformationControllers[index].addListener(listener);
}
return () {
for (var index = 0; index < transformationControllers.length; index++) {
transformationControllers[index]
..removeListener(listeners[index])
..dispose();
}
};
}, [transformationControllers]);
useEffect(() {
var cancelled = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!cancelled && context.mounted) {
_precacheViewerImages(context, images, currentIndex.value);
}
});
return () => cancelled = true;
}, [images]);
useEffect(() {
return () {
final listener = zoomResetListener.value;
if (listener != null) {
zoomResetController.removeListener(listener);
}
};
}, [zoomResetController]);
void onPageChanged(int index) {
_precacheViewerImages(context, images, index);
currentIndex.value = index;
isTransformed.value = _hasImageTransform(
transformationControllers[index].value,
);
disableHeroOnDismiss.value = index != safeInitialIndex;
dragOffset.value = 0;
isDragging.value = false;
}
_snapBackController
..reset()
..addListener(listener);
_snapBackController.forward().whenCompleteOrCancel(() {
_snapBackController.removeListener(listener);
});
}
bool get _canDismissWithHero => !_isTransformed || _disableHeroOnDismiss;
Future<void> _prepareHeroFallbackDismiss() async {
if (_canDismissWithHero) {
return;
void onFilmstripScrubUpdate(double delta) {
if (isTransformed.value || !pageController.hasClients) return;
final position = pageController.position;
final viewport = position.viewportDimension;
if (viewport <= 0) return;
final target =
(pageController.offset - ((delta / _filmstripScrubExtent) * viewport))
.clamp(position.minScrollExtent, position.maxScrollExtent)
.toDouble();
pageController.jumpTo(target);
}
setState(() {
_disableHeroOnDismiss = true;
});
await WidgetsBinding.instance.endOfFrame;
}
Future<void> _dismiss() async {
await _prepareHeroFallbackDismiss();
if (!mounted) {
return;
void onFilmstripScrubEnd() {
if (!pageController.hasClients) return;
final targetPage = (pageController.page ?? currentIndex.value.toDouble())
.round()
.clamp(0, images.length - 1);
if (MediaQuery.disableAnimationsOf(context)) {
pageController.jumpToPage(targetPage);
return;
}
pageController.animateToPage(
targetPage,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
);
}
Navigator.of(context).maybePop();
}
@override
Widget build(BuildContext context) {
void upgradeToFullResolution(int index) {
if (images[index].previewDecodeWidth == null ||
fullResolutionIndices.value.contains(index)) {
return;
}
fullResolutionIndices.value = {...fullResolutionIndices.value, index};
}
void onImageInteractionStart(int index, ScaleStartDetails details) {
if (details.pointerCount > 1) {
upgradeToFullResolution(index);
}
if (details.pointerCount == 1 && !isTransformed.value) {
isDragging.value = true;
}
}
void onImageInteractionUpdate(int index, ScaleUpdateDetails details) {
if (details.pointerCount > 1 || details.scale != 1) {
final needsFullResolution =
images[index].previewDecodeWidth != null &&
!fullResolutionIndices.value.contains(index);
if (isDragging.value || needsFullResolution) {
if (needsFullResolution) {
fullResolutionIndices.value = {
...fullResolutionIndices.value,
index,
};
}
isDragging.value = false;
dragOffset.value = 0;
}
return;
}
if (!isDragging.value || isTransformed.value) return;
dragOffset.value = (dragOffset.value + details.focalPointDelta.dy).clamp(
0.0,
MediaQuery.sizeOf(context).height,
);
}
void resetImageTransform(int index) {
final controller = transformationControllers[index];
if (!_hasImageTransform(controller.value)) {
return;
}
final previousListener = zoomResetListener.value;
if (previousListener != null) {
zoomResetController.removeListener(previousListener);
}
zoomResetController.stop();
if (MediaQuery.disableAnimationsOf(context)) {
controller.value = Matrix4.identity();
zoomResetListener.value = null;
return;
}
final animation =
Matrix4Tween(
begin: Matrix4.copy(controller.value),
end: Matrix4.identity(),
).animate(
CurvedAnimation(
parent: zoomResetController,
curve: Curves.easeOutCubic,
),
);
void listener() => controller.value = animation.value;
zoomResetListener.value = listener;
zoomResetController
..reset()
..addListener(listener)
..forward();
}
bool canDismissWithHero() =>
!isTransformed.value || disableHeroOnDismiss.value;
Future<void> prepareHeroFallbackDismiss() async {
if (canDismissWithHero()) {
return;
}
disableHeroOnDismiss.value = true;
await WidgetsBinding.instance.endOfFrame;
}
Future<void> dismiss() async {
await prepareHeroFallbackDismiss();
if (!context.mounted) {
return;
}
Navigator.of(context).maybePop();
}
void animateSnapBack() {
final tween = Tween<double>(begin: dragOffset.value, end: 0);
void listener() {
dragOffset.value = tween.evaluate(snapBackController);
}
snapBackController
..stop()
..reset()
..addListener(listener);
snapBackController
.animateWith(
SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 260),
bounce: 0.14,
),
0,
1,
0,
snapToEnd: true,
),
)
.whenCompleteOrCancel(() {
snapBackController.removeListener(listener);
});
}
void finishVerticalDismiss(double velocity) {
isDragging.value = false;
if (dragOffset.value > _dismissThreshold || velocity > _dismissVelocity) {
unawaited(dismiss());
} else {
animateSnapBack();
}
}
void onImageInteractionEnd(ScaleEndDetails details) {
if (!isDragging.value) return;
finishVerticalDismiss(details.velocity.pixelsPerSecond.dy);
}
Future<void> replyInThread() async {
final callback = onReply;
if (callback == null) return;
final route = ModalRoute.of(context);
await dismiss();
await route?.completed;
callback();
}
void showMoreActions() {
onMore?.call(context, images[currentIndex.value].url);
}
final viewportHeight = MediaQuery.sizeOf(context).height;
final dragProgress = (dragOffset.value / viewportHeight).clamp(0.0, 1.0);
final imageScale = 1 - (dragProgress * 0.1);
final chromeOpacity = (1 - (dragOffset.value / 160)).clamp(0.0, 1.0);
return PopScope<void>(
canPop: _canDismissWithHero,
canPop: canDismissWithHero(),
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
return;
}
unawaited(_dismiss());
unawaited(dismiss());
},
child: Scaffold(
key: const ValueKey('message-media-image-viewer'),
backgroundColor: Colors.black.withValues(
alpha: (1 - (_dragOffset.abs() / _backgroundFadeDivisor)).clamp(
alpha: (1 - (dragOffset.value.abs() / _backgroundFadeDivisor)).clamp(
0.3,
1.0,
),
@@ -258,52 +502,146 @@ class _MediaImageViewerPageState extends State<MediaImageViewerPage>
children: [
Positioned.fill(
child: Transform.translate(
offset: Offset(0, _dragOffset),
child: InteractiveViewer(
transformationController: _transformationController,
onInteractionStart: _onInteractionStart,
onInteractionUpdate: _onInteractionUpdate,
onInteractionEnd: _onInteractionEnd,
minScale: 1,
maxScale: 4,
child: Center(
child: HeroMode(
key: const ValueKey(
'message-media-image-viewer-hero-mode',
),
enabled: !_disableHeroOnDismiss,
child: Hero(
tag: widget.heroTag,
child: MediaImage(
url: widget.imageUrl,
boundDecodeToLayout: false,
fit: BoxFit.contain,
semanticLabel: widget.semanticLabel,
errorBuilder: (_, _, _) => const _MediaLoadFailure(
message: 'Failed to load image',
icon: LucideIcons.imageOff,
),
offset: Offset(0, dragOffset.value),
child: Transform.scale(
scale: imageScale,
child: PageView.builder(
key: const ValueKey('message-media-image-viewer-pages'),
controller: pageController,
physics: isTransformed.value
? const NeverScrollableScrollPhysics()
: const PageScrollPhysics(),
itemCount: images.length,
onPageChanged: onPageChanged,
itemBuilder: (context, index) {
final image = images[index];
final viewPadding = MediaQuery.viewPaddingOf(context);
return Padding(
padding: EdgeInsets.only(
top: viewPadding.top + 48 + Grid.xxs,
bottom: viewPadding.bottom + 56 + (Grid.xxs * 2),
),
),
),
child: LayoutBuilder(
builder: (context, constraints) {
final viewerSize = _imageViewerSize(
Size(constraints.maxWidth, constraints.maxHeight),
image.aspectRatio,
);
return GestureDetector(
key: ValueKey(
'message-media-image-viewer-gesture:$index',
),
behavior: HitTestBehavior.opaque,
onDoubleTap: () => resetImageTransform(index),
child: InteractiveViewer(
transformationController:
transformationControllers[index],
onInteractionStart: (details) =>
onImageInteractionStart(index, details),
onInteractionUpdate: (details) =>
onImageInteractionUpdate(index, details),
onInteractionEnd: onImageInteractionEnd,
panEnabled: isTransformed.value,
scaleEnabled: true,
minScale: 1,
maxScale: 4,
boundaryMargin: const EdgeInsets.all(Grid.xxl),
clipBehavior: Clip.none,
child: Align(
alignment: Alignment.center,
child: SizedBox(
width: viewerSize.width,
height: viewerSize.height,
child: HeroMode(
key: index == safeInitialIndex
? const ValueKey(
'message-media-image-viewer-hero-mode',
)
: ValueKey(
'message-media-image-viewer-hero-mode-$index',
),
enabled:
!disableHeroOnDismiss.value &&
index == safeInitialIndex,
child: MediaViewerHero(
tag: image.heroTag,
child: MediaImage(
key: ValueKey(
'message-media-image-viewer-image:$index',
),
url: image.url,
decodeWidth:
fullResolutionIndices.value
.contains(index)
? null
: image.previewDecodeWidth,
boundDecodeToLayout: false,
fit: BoxFit.contain,
semanticLabel: image.semanticLabel,
errorBuilder: (_, _, _) =>
const _MediaLoadFailure(
message: 'Failed to load image',
icon: LucideIcons.imageOff,
),
),
),
),
),
),
),
);
},
),
);
},
),
),
),
),
PositionedDirectional(
top: Grid.sm,
end: Grid.sm,
child: SafeArea(
child: DecoratedBox(
decoration: const BoxDecoration(
color: Color.fromRGBO(0, 0, 0, 0.56),
shape: BoxShape.circle,
bottom: 0,
start: 0,
end: 0,
child: Opacity(
opacity: chromeOpacity,
child: SafeArea(
child: _MediaViewerBottomControls(
images: images,
currentIndex: currentIndex.value,
pagePosition: pagePosition,
onScrubUpdate: onFilmstripScrubUpdate,
onScrubEnd: onFilmstripScrubEnd,
onSelect: (index) {
if (index == currentIndex.value) return;
if (MediaQuery.disableAnimationsOf(context)) {
pageController.jumpToPage(index);
return;
}
pageController.animateToPage(
index,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
);
},
onReply: onReply == null
? null
: () => unawaited(replyInThread()),
onMore: onMore == null ? null : showMoreActions,
),
child: IconButton(
),
),
),
PositionedDirectional(
top: 0,
end: Grid.sm,
child: Opacity(
opacity: chromeOpacity,
child: SafeArea(
child: _MediaViewerCircleButton(
key: const ValueKey('message-media-image-viewer-close'),
onPressed: _dismiss,
icon: LucideIcons.x,
tooltip: 'Close image viewer',
icon: const Icon(LucideIcons.x, color: Colors.white),
onPressed: () => unawaited(dismiss()),
),
),
),
@@ -315,17 +653,6 @@ class _MediaImageViewerPageState extends State<MediaImageViewerPage>
}
}
bool _hasImageTransform(Matrix4 transform) {
final storage = transform.storage;
for (var index = 0; index < storage.length; index++) {
if ((storage[index] - _identityTransformStorage[index]).abs() >
_identityTransformEpsilon) {
return true;
}
}
return false;
}
// StatefulWidget retained: owns a VideoPlayerController with async init and
// disposal — kept imperative deliberately (allowed exception).
class MediaVideoViewerPage extends StatefulWidget {
@@ -462,7 +789,12 @@ class _VideoLoadingPoster extends StatelessWidget {
else
_videoPlaceholder(context),
const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.24)),
const Center(child: CircularProgressIndicator()),
const Center(
child: CircularProgressIndicator(
strokeWidth: 3,
color: Colors.white,
),
),
],
),
);

View File

@@ -0,0 +1,300 @@
part of '../media_viewer_page.dart';
class _MediaViewerBottomControls extends StatelessWidget {
final List<MediaViewerImage> images;
final int currentIndex;
final ValueListenable<double> pagePosition;
final ValueChanged<double> onScrubUpdate;
final VoidCallback onScrubEnd;
final ValueChanged<int> onSelect;
final VoidCallback? onReply;
final VoidCallback? onMore;
const _MediaViewerBottomControls({
required this.images,
required this.currentIndex,
required this.pagePosition,
required this.onScrubUpdate,
required this.onScrubEnd,
required this.onSelect,
required this.onReply,
required this.onMore,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(Grid.sm, Grid.xxs, Grid.sm, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_MediaViewerCircleButton(
key: const ValueKey('message-media-image-viewer-reply-thread'),
icon: LucideIcons.messageSquareReply,
tooltip: 'Reply in thread',
onPressed: onReply,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: images.length > 1
? _MediaViewerFilmstrip(
key: const ValueKey('message-media-image-viewer-filmstrip'),
images: images,
currentIndex: currentIndex,
pagePosition: pagePosition,
onScrubUpdate: onScrubUpdate,
onScrubEnd: onScrubEnd,
onSelect: onSelect,
)
: const SizedBox(height: 56),
),
const SizedBox(width: Grid.xxs),
_MediaViewerCircleButton(
key: const ValueKey('message-media-image-viewer-more-actions'),
icon: LucideIcons.ellipsis,
tooltip: 'More image actions',
onPressed: onMore,
),
],
),
);
}
}
class _MediaViewerFilmstrip extends StatelessWidget {
final List<MediaViewerImage> images;
final int currentIndex;
final ValueListenable<double> pagePosition;
final ValueChanged<double> onScrubUpdate;
final VoidCallback onScrubEnd;
final ValueChanged<int> onSelect;
const _MediaViewerFilmstrip({
super.key,
required this.images,
required this.currentIndex,
required this.pagePosition,
required this.onScrubUpdate,
required this.onScrubEnd,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 56,
child: RepaintBoundary(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (details) =>
onScrubUpdate(details.primaryDelta ?? 0),
onHorizontalDragEnd: (_) => onScrubEnd(),
child: ValueListenableBuilder<double>(
valueListenable: pagePosition,
builder: (context, position, _) {
return LayoutBuilder(
builder: (context, constraints) {
const compactWidth = 40.0;
const focusedWidth = 72.0;
const itemHeight = 52.0;
const spacing = Grid.half;
final clampedPosition = position
.clamp(0.0, images.length - 1.0)
.toDouble();
final widths = <double>[];
final proximities = <double>[];
final centers = <double>[];
var cursor = 0.0;
for (var index = 0; index < images.length; index++) {
final proximity = (1 - (index - clampedPosition).abs())
.clamp(0.0, 1.0)
.toDouble();
final width =
compactWidth +
((focusedWidth - compactWidth) * proximity);
widths.add(width);
proximities.add(proximity);
centers.add(cursor + (width / 2));
cursor += width + spacing;
}
final lowerIndex = clampedPosition.floor();
final upperIndex = clampedPosition.ceil();
final fraction = clampedPosition - lowerIndex;
final lowerCenter = centers[lowerIndex];
final upperCenter = centers[upperIndex];
final focusCenter =
lowerCenter + ((upperCenter - lowerCenter) * fraction);
final viewportCenter = constraints.maxWidth / 2;
return Stack(
clipBehavior: Clip.hardEdge,
children: [
for (var index = 0; index < images.length; index++)
Positioned(
left:
viewportCenter +
centers[index] -
focusCenter -
(widths[index] / 2),
top: Grid.quarter,
width: widths[index],
height: itemHeight,
child: _MediaViewerFilmstripImage(
image: images[index],
index: index,
proximity: proximities[index],
selected: index == currentIndex,
onSelect: onSelect,
),
),
],
);
},
);
},
),
),
),
);
}
}
class _MediaViewerFilmstripImage extends StatelessWidget {
final MediaViewerImage image;
final int index;
final double proximity;
final bool selected;
final ValueChanged<int> onSelect;
const _MediaViewerFilmstripImage({
required this.image,
required this.index,
required this.proximity,
required this.selected,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
final borderWidth = 1 + (1.5 * proximity);
return Semantics(
button: true,
selected: selected,
label: selected
? 'Image ${index + 1}, selected'
: 'Show image ${index + 1}',
child: GestureDetector(
key: ValueKey('message-media-image-viewer-thumbnail:$index'),
onTap: () => onSelect(index),
child: Container(
height: 52,
padding: EdgeInsets.all(borderWidth),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.sm),
border: Border.all(
color: Colors.white.withValues(alpha: 0.28 + (0.72 * proximity)),
width: borderWidth,
),
),
child: ClipRRect(
key: ValueKey('message-media-image-viewer-thumbnail-clip:$index'),
borderRadius: BorderRadius.circular(Radii.sm - borderWidth),
clipBehavior: Clip.antiAlias,
child: Opacity(
opacity: 0.62 + (0.38 * proximity),
child: MediaImage(
url: image.url,
decodeWidth: 72,
fit: BoxFit.cover,
semanticLabel: image.semanticLabel,
errorBuilder: (_, _, _) => const ColoredBox(
color: Color.fromRGBO(255, 255, 255, 0.12),
child: Icon(
LucideIcons.imageOff,
color: Colors.white70,
size: 18,
),
),
),
),
),
),
),
);
}
}
class _MediaViewerCircleButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
const _MediaViewerCircleButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: 48,
child: onPressed == null
? const SizedBox.shrink()
: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.16),
shape: BoxShape.circle,
),
child: IconButton(
onPressed: onPressed,
tooltip: tooltip,
icon: Icon(icon, color: Colors.white, size: 20),
),
),
);
}
}
Size _imageViewerSize(Size viewport, double? aspectRatio) {
if (aspectRatio == null || aspectRatio <= 0) {
return viewport;
}
final safeAspectRatio = aspectRatio.clamp(0.05, 20.0).toDouble();
if (viewport.aspectRatio > safeAspectRatio) {
return Size(viewport.height * safeAspectRatio, viewport.height);
}
return Size(viewport.width, viewport.width / safeAspectRatio);
}
void _precacheViewerImages(
BuildContext context,
List<MediaViewerImage> images,
int focusedIndex,
) {
for (var index = focusedIndex - 2; index <= focusedIndex + 2; index++) {
if (index < 0 || index >= images.length) {
continue;
}
final provider = images[index].preloadProvider;
if (provider == null) {
continue;
}
unawaited(precacheImage(provider, context, onError: (_, _) {}));
}
}
bool _hasImageTransform(Matrix4 transform) {
final storage = transform.storage;
for (var index = 0; index < storage.length; index++) {
if ((storage[index] - _identityTransformStorage[index]).abs() >
_identityTransformEpsilon) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,22 @@
part of '../media_viewer_page.dart';
class _MediaViewerRouteTransition extends StatelessWidget {
final Animation<double> animation;
final Widget child;
const _MediaViewerRouteTransition({
required this.animation,
required this.child,
});
@override
Widget build(BuildContext context) {
final fade = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInOutCubic,
);
return FadeTransition(opacity: fade, child: child);
}
}

View File

@@ -1,10 +1,18 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:share_plus/share_plus.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/deeplink/deep_link.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
@@ -167,6 +175,214 @@ void showMessageActions({
);
}
/// Image-focused actions shown from the full-screen viewer.
void showImageActions({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
required String imageUrl,
required bool canManageMessage,
VoidCallback? onDeleted,
}) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.download),
title: const Text('Save image'),
onTap: () {
Navigator.of(sheetContext).pop();
unawaited(_saveImage(context, ref, imageUrl));
},
),
ListTile(
leading: const Icon(LucideIcons.share2),
title: const Text('Share image'),
onTap: () {
final renderBox = context.findRenderObject() as RenderBox?;
final shareOrigin = renderBox == null
? null
: renderBox.localToGlobal(Offset.zero) & renderBox.size;
Navigator.of(sheetContext).pop();
unawaited(
_shareImage(context, ref, imageUrl, shareOrigin: shareOrigin),
);
},
),
ListTile(
leading: const Icon(LucideIcons.link2),
title: const Text('Copy image link'),
onTap: () {
Navigator.of(sheetContext).pop();
copyToClipboard(
context,
imageUrl,
message: 'Image link copied',
);
},
),
if (canManageMessage) ...[
const SheetDivider(),
ListTile(
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
'Delete message',
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(
context: context,
ref: ref,
channelId: channelId,
messageId: message.id,
onDeleted: onDeleted,
);
},
),
],
],
),
),
),
);
}
@immutable
class _DownloadedImage {
final Uint8List bytes;
final String filename;
const _DownloadedImage({required this.bytes, required this.filename});
}
Future<_DownloadedImage> _downloadImage(WidgetRef ref, String imageUrl) async {
final response = await ref
.read(mediaHttpClientProvider)
.get(
Uri.parse(imageUrl),
headers: ref.read(mediaGetAuthServiceProvider).headersFor(imageUrl),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw HttpException(
'Image download failed (${response.statusCode})',
uri: Uri.parse(imageUrl),
);
}
return _DownloadedImage(
bytes: response.bodyBytes,
filename: downloadedImageFilename(
imageUrl,
response.headers['content-type'],
),
);
}
/// Returns a safe image filename while preserving supported image formats.
@visibleForTesting
String downloadedImageFilename(String imageUrl, String? contentType) {
final pathSegments = Uri.tryParse(imageUrl)?.pathSegments;
final rawName = pathSegments == null || pathSegments.isEmpty
? ''
: pathSegments.last;
final safeName = rawName
.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '-')
.replaceAll(RegExp(r'-+'), '-');
if (RegExp(
r'\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$',
caseSensitive: false,
).hasMatch(safeName)) {
return safeName;
}
final extension = switch (contentType?.split(';').first.trim()) {
'image/avif' => '.avif',
'image/gif' => '.gif',
'image/heic' => '.heic',
'image/heif' => '.heif',
'image/png' => '.png',
'image/webp' => '.webp',
_ => '.jpg',
};
return 'buzz-${DateTime.now().millisecondsSinceEpoch}$extension';
}
Future<void> _saveImage(
BuildContext context,
WidgetRef ref,
String imageUrl,
) async {
final messenger = ScaffoldMessenger.maybeOf(context);
try {
final needsPhotoLibraryPermission =
defaultTargetPlatform == TargetPlatform.iOS ||
await requiresLegacyMediaStoragePermission();
if (needsPhotoLibraryPermission) {
final permission = await PhotoManager.requestPermissionExtend(
requestOption: const PermissionRequestOption(
iosAccessLevel: IosAccessLevel.addOnly,
androidPermission: AndroidPermission(
type: RequestType.image,
mediaLocation: false,
),
),
);
if (!permission.isAuth) {
throw const FileSystemException(
'Photo library permission was not granted.',
);
}
}
final image = await _downloadImage(ref, imageUrl);
await PhotoManager.editor.saveImage(image.bytes, filename: image.filename);
messenger?.showSnackBar(
const SnackBar(content: Text('Image saved to Photos')),
);
} catch (_) {
messenger?.showSnackBar(
const SnackBar(content: Text('Could not save image')),
);
}
}
Future<void> _shareImage(
BuildContext context,
WidgetRef ref,
String imageUrl, {
Rect? shareOrigin,
}) async {
final messenger = ScaffoldMessenger.maybeOf(context);
try {
final image = await _downloadImage(ref, imageUrl);
final directory = await getTemporaryDirectory();
final file = File(
'${directory.path}${Platform.pathSeparator}${image.filename}',
);
await file.writeAsBytes(image.bytes, flush: true);
await SharePlus.instance.share(
ShareParams(files: [XFile(file.path)], sharePositionOrigin: shareOrigin),
);
} catch (_) {
messenger?.showSnackBar(
const SnackBar(content: Text('Could not share image')),
);
}
}
/// Canonical `buzz://message` link for a timeline message, including thread
/// context when the message is a reply.
String messageLinkFor({
@@ -496,6 +712,7 @@ void _confirmDelete({
required WidgetRef ref,
required String channelId,
required String messageId,
VoidCallback? onDeleted,
}) {
showDialog<void>(
context: context,
@@ -508,17 +725,19 @@ void _confirmDelete({
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
onPressed: () async {
Navigator.of(dialogContext).pop();
final messenger = ScaffoldMessenger.of(context);
ref
.read(channelActionsProvider)
.deleteMessage(channelId: channelId, eventId: messageId)
.catchError((Object error) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to delete message: $error')),
);
});
try {
await ref
.read(channelActionsProvider)
.deleteMessage(channelId: channelId, eventId: messageId);
onDeleted?.call();
} catch (error) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to delete message: $error')),
);
}
},
style: FilledButton.styleFrom(
backgroundColor: dialogContext.colors.error,

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
@@ -21,6 +22,8 @@ import '../../shared/custom_emoji/custom_emoji_render.dart';
import 'media_viewer_page.dart';
import 'message_media.dart';
part 'message_content/media_carousel.dart';
const _messageMediaMaxInlineWidth = 320.0;
const _messageMediaMaxImageHeight = 240.0;
@@ -92,10 +95,25 @@ class MessageContent extends HookConsumerWidget {
/// mentioned user's pubkey.
final void Function(String pubkey)? onMentionTap;
/// Opens the message's thread from the full-screen image viewer.
final VoidCallback? onMediaReply;
/// Opens message-specific actions for an image in the full-screen viewer.
final MediaViewerMoreAction? onMediaMore;
final TextStyle? baseStyle;
final int? maxLines;
/// Allows a multi-image carousel to reclaim leading space reserved by the
/// surrounding message layout, while keeping its image count aligned with
/// the message body.
final double mediaCarouselLeadingOverflow;
/// Allows a multi-image carousel to continue through the trailing page
/// gutter while keeping its first image and count aligned with the body.
final double mediaCarouselTrailingOverflow;
const MessageContent({
super.key,
required this.content,
@@ -105,8 +123,12 @@ class MessageContent extends HookConsumerWidget {
this.tags = const [],
this.onChannelTap,
this.onMentionTap,
this.onMediaReply,
this.onMediaMore,
this.baseStyle,
this.maxLines,
this.mediaCarouselLeadingOverflow = 0,
this.mediaCarouselTrailingOverflow = 0,
});
@override
@@ -115,6 +137,10 @@ class MessageContent extends HookConsumerWidget {
baseStyle ??
context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface);
final imetaByUrl = parseImetaTags(tags);
final trailingGallery = maxLines == null
? _extractTrailingImageGallery(content, imetaByUrl)
: null;
final markdownContent = trailingGallery?.content ?? content;
final customEmoji = _mergeCustomEmoji(
customEmojiFromTags(tags),
ref.watch(customEmojiListProvider),
@@ -124,7 +150,7 @@ class MessageContent extends HookConsumerWidget {
// Convert autolinks and bare URLs to standard markdown links,
// but skip content inside backticks (inline code / fenced blocks).
final buffer = StringBuffer();
final parts = content.split('`');
final parts = markdownContent.split('`');
for (var i = 0; i < parts.length; i++) {
if (i.isOdd) {
// Inside backticks — preserve as-is.
@@ -186,9 +212,9 @@ class MessageContent extends HookConsumerWidget {
result = '\u200B$result';
}
return result;
}, [content, mentionNames]);
}, [markdownContent, mentionNames]);
return GptMarkdown(
final markdown = GptMarkdown(
finalContent,
style: style,
followLinkColor: false,
@@ -210,6 +236,25 @@ class MessageContent extends HookConsumerWidget {
...MarkdownComponent.inlineComponents,
],
);
if (trailingGallery == null) return markdown;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (trailingGallery.content.trim().isNotEmpty) markdown,
_MessageImageCarousel(
key: ValueKey(
trailingGallery.items.map((item) => item.url).join('\u0000'),
),
items: trailingGallery.items,
leadingOverflow: mediaCarouselLeadingOverflow,
trailingOverflow: mediaCarouselTrailingOverflow,
onReply: onMediaReply,
onMore: onMediaMore,
),
],
);
}
Widget _buildMedia(BuildContext context, String imageUrl, ImetaEntry? imeta) {
@@ -221,6 +266,8 @@ class MessageContent extends HookConsumerWidget {
url: imageUrl,
imeta: imeta,
semanticLabel: imeta?.alt ?? 'Message image',
onReply: onMediaReply,
onMore: onMediaMore,
);
}
@@ -298,17 +345,22 @@ class _MessageImagePreview extends HookConsumerWidget {
final String url;
final ImetaEntry? imeta;
final String semanticLabel;
final VoidCallback? onReply;
final MediaViewerMoreAction? onMore;
const _MessageImagePreview({
required this.url,
required this.imeta,
required this.semanticLabel,
required this.onReply,
required this.onMore,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final heroTag = useMemoized(() => Object());
final layout = _resolveImagePreviewLayout(context, imeta?.aspectRatio);
final previewDecodeWidth = layout.width ?? _messageMediaMaxWidth(context);
return Padding(
padding: const EdgeInsets.only(top: Grid.half),
@@ -318,6 +370,10 @@ class _MessageImagePreview extends HookConsumerWidget {
imageUrl: url,
heroTag: heroTag,
semanticLabel: semanticLabel,
previewDecodeWidth: previewDecodeWidth,
aspectRatio: imeta?.aspectRatio,
onReply: onReply,
onMore: onMore,
),
child: _MessageMediaPreviewFrame(
previewKey: ValueKey('message-media-image-preview:$url'),
@@ -325,15 +381,19 @@ class _MessageImagePreview extends HookConsumerWidget {
width: layout.width,
height: layout.height,
constraints: layout.constraints,
child: Hero(
child: MediaViewerHero(
tag: heroTag,
child: MediaImage(
url: url,
fit: layout.fit,
semanticLabel: semanticLabel,
errorBuilder: (_, _, _) => _MediaPreviewFallback(
icon: LucideIcons.imageOff,
label: 'Image unavailable',
child: ClipRRect(
borderRadius: BorderRadius.circular(Radii.md),
child: MediaImage(
url: url,
decodeWidth: previewDecodeWidth,
fit: layout.fit,
semanticLabel: semanticLabel,
errorBuilder: (_, _, _) => _MediaPreviewFallback(
icon: LucideIcons.imageOff,
label: 'Image unavailable',
),
),
),
),

View File

@@ -0,0 +1,293 @@
part of '../message_content.dart';
const _messageMediaCarouselHeight = 220.0;
@immutable
class _MessageGalleryItem {
final String url;
final String semanticLabel;
final double? aspectRatio;
const _MessageGalleryItem({
required this.url,
required this.semanticLabel,
required this.aspectRatio,
});
}
@immutable
class _TrailingImageGallery {
final String content;
final List<_MessageGalleryItem> items;
const _TrailingImageGallery({required this.content, required this.items});
}
class _MessageGalleryPrecache extends HookWidget {
final List<ImageProvider<Object>> providers;
final int focusedIndex;
const _MessageGalleryPrecache({
required this.providers,
required this.focusedIndex,
});
@override
Widget build(BuildContext context) {
final providerSignature = Object.hashAll(providers);
useEffect(() {
var cancelled = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (cancelled || !context.mounted) {
return;
}
for (var index = focusedIndex - 2; index <= focusedIndex + 2; index++) {
if (index < 0 || index >= providers.length) {
continue;
}
unawaited(
precacheImage(providers[index], context, onError: (_, _) {}),
);
}
});
return () => cancelled = true;
}, [focusedIndex, providerSignature]);
return const SizedBox.shrink();
}
}
_TrailingImageGallery? _extractTrailingImageGallery(
String content,
Map<String, ImetaEntry> imetaByUrl,
) {
final lines = content.split('\n');
var cursor = lines.length - 1;
while (cursor >= 0 && lines[cursor].trim().isEmpty) {
cursor -= 1;
}
final items = <_MessageGalleryItem>[];
final imagePattern = RegExp(r'^!\[([^\]]*)\]\((https?://[^)\s]+)\)$');
while (cursor >= 0) {
final match = imagePattern.firstMatch(lines[cursor].trim());
if (match == null) break;
final url = match.group(2)!;
final imeta = imetaByUrl[url];
if (classifyMediaUrl(url, imeta: imeta) == MessageMediaKind.video) {
break;
}
final markdownLabel = match.group(1)?.trim();
items.insert(
0,
_MessageGalleryItem(
url: url,
semanticLabel:
imeta?.alt ??
(markdownLabel?.isNotEmpty == true
? markdownLabel!
: 'Message image'),
aspectRatio: imeta?.aspectRatio,
),
);
cursor -= 1;
}
if (items.length < 2) return null;
return _TrailingImageGallery(
content: lines.take(cursor + 1).join('\n').trimRight(),
items: items,
);
}
class _MessageImageCarousel extends HookConsumerWidget {
final List<_MessageGalleryItem> items;
final double leadingOverflow;
final double trailingOverflow;
final VoidCallback? onReply;
final MediaViewerMoreAction? onMore;
const _MessageImageCarousel({
super.key,
required this.items,
required this.leadingOverflow,
required this.trailingOverflow,
required this.onReply,
required this.onMore,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final itemSignature = items.map((item) => item.url).join('\u0000');
final heroTags = useMemoized(
() => [for (var index = 0; index < items.length; index++) Object()],
[itemSignature],
);
final controller = usePageController(viewportFraction: 0.9);
final currentIndex = useState(0);
final mediaAuth = ref.watch(mediaGetAuthServiceProvider);
final mediaClient = ref.watch(mediaHttpClientProvider);
return Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${items.length} images',
key: const ValueKey('message-media-carousel-count'),
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: Grid.half),
LayoutBuilder(
builder: (context, constraints) {
final contentWidth = constraints.hasBoundedWidth
? constraints.maxWidth
: _messageMediaMaxWidth(context);
final carouselWidth =
contentWidth + leadingOverflow + trailingOverflow;
final leadingExtent = leadingOverflow;
final isLeftToRight =
Directionality.of(context) == TextDirection.ltr;
final previewDecodeWidths = [
for (var index = 0; index < items.length; index++)
math.max(
1.0,
carouselWidth * controller.viewportFraction -
(index == items.length - 1 ? 0 : Grid.half),
),
];
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
final previewProviders = [
for (var index = 0; index < items.length; index++)
ResizeImage.resizeIfNeeded(
(previewDecodeWidths[index] * devicePixelRatio).ceil(),
null,
MediaImageProvider(
url: items[index].url,
auth: mediaAuth,
client: mediaClient,
),
),
];
final viewerItems = [
for (var index = 0; index < items.length; index++)
MediaViewerImage(
url: items[index].url,
heroTag: heroTags[index],
semanticLabel: items[index].semanticLabel,
previewDecodeWidth: previewDecodeWidths[index],
aspectRatio: items[index].aspectRatio,
preloadProvider: previewProviders[index],
),
];
final carousel = SizedBox(
key: const ValueKey('message-media-carousel'),
width: carouselWidth,
height: _messageMediaCarouselHeight,
child: PageView.builder(
controller: controller,
clipBehavior: Clip.none,
padEnds: false,
itemCount: items.length,
onPageChanged: (index) => currentIndex.value = index,
itemBuilder: (context, index) {
final item = items[index];
return Padding(
padding: EdgeInsetsDirectional.only(
end: index == items.length - 1 ? 0 : Grid.half,
),
child: Semantics(
button: true,
excludeSemantics: true,
label: 'Open ${item.semanticLabel}',
child: GestureDetector(
key: ValueKey(
'message-media-carousel-item:${item.url}',
),
onTap: () => openImageViewer(
context,
imageUrl: item.url,
heroTag: heroTags[index],
semanticLabel: item.semanticLabel,
previewDecodeWidth: previewDecodeWidths[index],
aspectRatio: item.aspectRatio,
galleryItems: viewerItems,
initialIndex: index,
onReply: onReply,
onMore: onMore,
),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(
color: context.colors.outlineVariant,
),
),
child: MediaViewerHero(
tag: heroTags[index],
child: ClipRRect(
borderRadius: BorderRadius.circular(Radii.md),
child: MediaImage(
url: item.url,
decodeWidth: previewDecodeWidths[index],
fit: BoxFit.cover,
semanticLabel: item.semanticLabel,
errorBuilder: (_, _, _) =>
const _MediaPreviewFallback(
icon: LucideIcons.imageOff,
label: 'Image unavailable',
),
),
),
),
),
),
),
);
},
),
);
final carouselSurface = Stack(
clipBehavior: Clip.none,
children: [
carousel,
_MessageGalleryPrecache(
providers: previewProviders,
focusedIndex: currentIndex.value,
),
],
);
if (leadingExtent <= 0 && trailingOverflow <= 0) {
return carouselSurface;
}
return SizedBox(
width: contentWidth,
height: _messageMediaCarouselHeight,
child: OverflowBox(
alignment: AlignmentDirectional.centerStart,
minWidth: carouselWidth,
maxWidth: carouselWidth,
child: Transform.translate(
offset: Offset(
isLeftToRight ? -leadingExtent : leadingExtent,
0,
),
child: carouselSurface,
),
),
);
},
),
],
),
);
}
}

View File

@@ -221,6 +221,7 @@ class ThreadDetailPage extends HookConsumerWidget {
reply.createdAt,
);
final showAuthor =
reply.hasAttachments ||
prevReply == null ||
showDayDivider ||
prevReply.pubkey.toLowerCase() !=
@@ -455,6 +456,10 @@ class _ThreadMessage extends ConsumerWidget {
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
ref.read(userCacheProvider.notifier).get(pk);
final displayName = profile?.label ?? shortPubkey(message.pubkey);
final canManageMessage =
currentPubkey?.toLowerCase() == pk ||
(profile?.ownerPubkey != null &&
profile?.ownerPubkey == currentPubkey?.toLowerCase());
final userCache = ref.watch(userCacheProvider);
final knownAgentPubkeys = ref.watch(mentionAgentPubkeysProvider(channelId));
@@ -478,10 +483,7 @@ class _ThreadMessage extends ConsumerWidget {
ref: ref,
message: message,
channelId: channelId,
canManageMessage:
currentPubkey?.toLowerCase() == pk ||
(profile?.ownerPubkey != null &&
profile?.ownerPubkey == currentPubkey?.toLowerCase()),
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
@@ -568,6 +570,38 @@ class _ThreadMessage extends ConsumerWidget {
baseStyle: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurface,
),
mediaCarouselTrailingOverflow: Grid.gutter,
onMediaReply: allMessages == null
? null
: () {
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
onMediaMore: (viewerContext, imageUrl) =>
showImageActions(
context: viewerContext,
ref: ref,
message: message,
channelId: channelId,
imageUrl: imageUrl,
canManageMessage: canManageMessage,
onDeleted: () {
if (viewerContext.mounted) {
Navigator.of(viewerContext).maybePop();
}
},
),
onChannelTap: (targetChannelId) {
openChannelLink(
context: context,

View File

@@ -171,6 +171,11 @@ class TimelineMessage {
this.parentId,
this.rootId,
});
/// Attachment messages stay visually distinct from surrounding messages,
/// even when several are sent by the same author in quick succession.
bool get hasAttachments =>
tags.any((tag) => tag.isNotEmpty && tag.first == 'imeta');
}
@immutable

View File

@@ -21,6 +21,8 @@ const _mediaUploadPlatformChannelName = 'buzz/media_upload';
const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload';
const _transcodeVideoToMp4Method = 'transcodeVideoToMp4';
const _transcodeImageToJpegMethod = 'transcodeImageToJpeg';
const _requiresLegacyMediaStoragePermissionMethod =
'requiresLegacyMediaStoragePermission';
const _readClipboardImageMethod = 'readClipboardImage';
const _clipboardHasImageMethod = 'clipboardHasImage';
const _uploadAuthKind = 24242;
@@ -39,6 +41,17 @@ final _mediaUploadPlatformChannel = MethodChannel(
_mediaUploadPlatformChannelName,
);
/// Whether saving media needs Android's pre-scoped-storage runtime permission.
Future<bool> requiresLegacyMediaStoragePermission() async {
if (defaultTargetPlatform != TargetPlatform.android) {
return false;
}
return await _mediaUploadPlatformChannel.invokeMethod<bool>(
_requiresLegacyMediaStoragePermissionMethod,
) ??
false;
}
const _allowedImageMimeTypes = {
'image/jpeg',
'image/png',

View File

@@ -1000,6 +1000,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.2"
photo_manager:
dependency: "direct main"
description:
name: photo_manager
sha256: "4f7de6c9778993c5c54cf1fb2eaa8d5c27c0771dc24a4dfca341aa81aef13fe1"
url: "https://pub.dev"
source: hosted
version: "3.11.0"
platform:
dependency: transitive
description:
@@ -1096,6 +1104,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.8"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c"
url: "https://pub.dev"
source: hosted
version: "13.3.0"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
shared_preferences:
dependency: "direct main"
description:

View File

@@ -29,6 +29,7 @@ dependencies:
file_selector: ^1.1.0
camera: ^0.12.0+2
image_picker: ^1.1.2
photo_manager: ^3.11.0
video_player: ^2.10.1
package_info_plus: ^10.0.0
app_badge_plus: ^1.2.10
@@ -36,6 +37,7 @@ dependencies:
scrollable_positioned_list: ^0.3.8
open_filex: ^4.7.0
path_provider: ^2.1.6
share_plus: ^13.3.0
dev_dependencies:
flutter_test:

View File

@@ -149,6 +149,37 @@ Future<void> _pumpSheet(
await tester.pumpAndSettle();
}
Future<void> _pumpImageSheet(
WidgetTester tester, {
required TimelineMessage message,
bool canManageMessage = false,
}) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Consumer(
builder: (context, ref, _) => TextButton(
onPressed: () => showImageActions(
context: context,
ref: ref,
message: message,
channelId: _channelId,
imageUrl: 'https://example.com/photo.png',
canManageMessage: canManageMessage,
),
child: const Text('open image actions'),
),
),
),
),
),
);
await tester.tap(find.text('open image actions'));
await tester.pumpAndSettle();
}
void main() {
group('showMessageActions', () {
testWidgets('shows parity actions for a regular message', (tester) async {
@@ -367,6 +398,40 @@ void main() {
});
});
group('showImageActions', () {
testWidgets('labels the destructive action as deleting the message', (
tester,
) async {
await _pumpImageSheet(
tester,
message: _message(),
canManageMessage: true,
);
expect(find.text('Delete message'), findsOneWidget);
expect(find.text('Delete upload'), findsNothing);
});
});
group('downloadedImageFilename', () {
test('preserves gif file extensions', () {
expect(
downloadedImageFilename('https://example.com/animation.gif', null),
'animation.gif',
);
});
test('uses gif extension for gif content types', () {
expect(
downloadedImageFilename(
'https://example.com/download',
'image/gif; charset=binary',
),
matches(RegExp(r'^buzz-\d+\.gif$')),
);
});
});
group('messageLinkFor', () {
test('builds a canonical link with thread context', () {
expect(

View File

@@ -9,12 +9,23 @@ import 'package:buzz/features/channels/media_viewer_page.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
Widget _testable(Widget child, {List<Override> overrides = const []}) {
Widget _testable(
Widget child, {
List<Override> overrides = const [],
bool disableAnimations = false,
}) {
return ProviderScope(
overrides: overrides,
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(body: child),
home: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(disableAnimations: disableAnimations),
child: Scaffold(body: child),
),
),
),
);
}
@@ -182,13 +193,24 @@ void main() {
);
expect(route, isA<PageRouteBuilder<void>>());
expect(route.transitionDuration, const Duration(milliseconds: 280));
expect(route.transitionDuration, const Duration(milliseconds: 260));
expect(
route.reverseTransitionDuration,
const Duration(milliseconds: 220),
const Duration(milliseconds: 170),
);
});
test('buildImageViewerRoute disables motion when requested', () {
final route = buildImageViewerRoute(
imageUrl: 'https://example.com/media/image.png',
heroTag: Object(),
disableAnimations: true,
);
expect(route.transitionDuration, Duration.zero);
expect(route.reverseTransitionDuration, Duration.zero);
});
group('plain text', () {
testWidgets('renders simple text', (tester) async {
await tester.pumpWidget(
@@ -430,6 +452,197 @@ void main() {
);
});
testWidgets(
'groups uploaded photos into a carousel and opens the full gallery',
(tester) async {
const first = 'https://example.com/media/one.png';
const second = 'https://example.com/media/two.png';
const third = 'https://example.com/media/three.png';
await tester.pumpWidget(
_testable(
const MessageContent(
content:
'''
Photos
![image]($first)
![image]($second)
![image]($third)
''',
tags: [
['imeta', 'url $first', 'm image/png', 'alt First photo'],
['imeta', 'url $second', 'm image/png', 'alt Second photo'],
['imeta', 'url $third', 'm image/png', 'alt Third photo'],
],
),
),
);
await tester.pumpAndSettle();
final carousel = find.byKey(const ValueKey('message-media-carousel'));
expect(carousel, findsOneWidget);
expect(find.text('3 images'), findsOneWidget);
await tester.drag(carousel, const Offset(-600, 0));
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('message-media-carousel-item:$second')),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('message-media-image-viewer')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('message-media-image-viewer-filmstrip')),
findsOneWidget,
);
expect(
find.byKey(
const ValueKey('message-media-image-viewer-thumbnail:1'),
),
findsOneWidget,
);
final displayedImage = tester.widget<MediaImage>(
find.byKey(const ValueKey('message-media-image-viewer-image:1')),
);
expect(displayedImage.decodeWidth, isNotNull);
final selectedThumbnailClip = tester.widget<ClipRRect>(
find.byKey(
const ValueKey('message-media-image-viewer-thumbnail-clip:1'),
),
);
final selectedThumbnailRadius =
selectedThumbnailClip.borderRadius as BorderRadius;
expect(
selectedThumbnailRadius.topLeft.x,
closeTo(Radii.sm - 2.5, 0.01),
);
await tester.fling(
find.byKey(const ValueKey('message-media-image-viewer-pages')),
const Offset(-700, 0),
1200,
);
await tester.pumpAndSettle();
final thirdThumbnail = find.byKey(
const ValueKey('message-media-image-viewer-thumbnail:2'),
);
final thirdSemantics = tester.widget<Semantics>(
find
.ancestor(of: thirdThumbnail, matching: find.byType(Semantics))
.first,
);
expect(thirdSemantics.properties.selected, isTrue);
},
);
testWidgets(
'jumps to a selected gallery thumbnail when motion is disabled',
(tester) async {
const first = 'https://example.com/media/reduced-motion-one.png';
const second = 'https://example.com/media/reduced-motion-two.png';
await tester.pumpWidget(
_testable(
const MessageContent(
content:
'''
![image]($first)
![image]($second)
''',
tags: [
['imeta', 'url $first', 'm image/png'],
['imeta', 'url $second', 'm image/png'],
],
),
disableAnimations: true,
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('message-media-carousel-item:$first')),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(
const ValueKey('message-media-image-viewer-thumbnail:1'),
),
);
await tester.pumpAndSettle();
final selectedThumbnail = tester.widget<Semantics>(
find
.ancestor(
of: find.byKey(
const ValueKey('message-media-image-viewer-thumbnail:1'),
),
matching: find.byType(Semantics),
)
.first,
);
expect(selectedThumbnail.properties.selected, isTrue);
expect(tester.takeException(), isNull);
},
);
testWidgets('resets carousel paging when gallery images change', (
tester,
) async {
const firstGallery = [
'https://example.com/media/first-a.png',
'https://example.com/media/first-b.png',
'https://example.com/media/first-c.png',
];
const secondGallery = [
'https://example.com/media/second-a.png',
'https://example.com/media/second-b.png',
];
Widget gallery(List<String> urls) => _testable(
MessageContent(
content: urls.map((url) => '![image]($url)').join('\n'),
tags: [
for (final url in urls) ['imeta', 'url $url', 'm image/png'],
],
),
);
await tester.pumpWidget(gallery(firstGallery));
await tester.pumpAndSettle();
final firstCarousel = tester.widget<PageView>(
find.descendant(
of: find.byKey(const ValueKey('message-media-carousel')),
matching: find.byType(PageView),
),
);
await tester.fling(
find.byKey(const ValueKey('message-media-carousel')),
const Offset(-700, 0),
1200,
);
await tester.pumpAndSettle();
expect(firstCarousel.controller!.page, greaterThan(0));
await tester.pumpWidget(gallery(secondGallery));
await tester.pumpAndSettle();
final secondCarousel = tester.widget<PageView>(
find.descendant(
of: find.byKey(const ValueKey('message-media-carousel')),
matching: find.byType(PageView),
),
);
expect(
secondCarousel.controller,
isNot(same(firstCarousel.controller)),
);
expect(secondCarousel.controller!.page, 0);
});
testWidgets(
'disables hero on close after the fullscreen image is transformed',
(tester) async {
@@ -484,6 +697,90 @@ void main() {
},
);
testWidgets('double tap resets the fullscreen image transform', (
tester,
) async {
const imageUrl = 'https://example.com/media/double-tap-reset.png';
await tester.pumpWidget(
_testable(
const MessageContent(
content:
'Look\n![image](https://example.com/media/double-tap-reset.png)',
tags: [
[
'imeta',
'url https://example.com/media/double-tap-reset.png',
'm image/png',
],
],
),
),
);
await tester.pumpAndSettle();
final transformationController = await _openImageViewer(
tester,
imageUrl,
);
_applyImageViewerTransform(
transformationController,
dx: 32,
dy: 24,
scale: 2,
);
await tester.pump();
final gestureSurface = find.byKey(
const ValueKey('message-media-image-viewer-gesture:0'),
);
await tester.tap(gestureSurface);
await tester.pump(const Duration(milliseconds: 50));
await tester.tap(gestureSurface);
await tester.pumpAndSettle();
expect(
transformationController.value.storage,
orderedEquals(Matrix4.identity().storage),
);
expect(_isImageViewerHeroEnabled(tester), isTrue);
});
testWidgets('swiping down dismisses the fullscreen gallery', (
tester,
) async {
const imageUrl = 'https://example.com/media/swipe-dismiss.png';
await tester.pumpWidget(
_testable(
const MessageContent(
content:
'Look\n![image](https://example.com/media/swipe-dismiss.png)',
tags: [
[
'imeta',
'url https://example.com/media/swipe-dismiss.png',
'm image/png',
],
],
),
),
);
await tester.pumpAndSettle();
await _openImageViewer(tester, imageUrl);
await tester.drag(
find.byKey(const ValueKey('message-media-image-viewer-gesture:0')),
const Offset(0, 180),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('message-media-image-viewer')),
findsNothing,
);
});
testWidgets(
'disables hero on back navigation after the fullscreen image is transformed',
(tester) async {