mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -240,7 +240,8 @@ class _MessageList extends HookConsumerWidget {
|
||||
|
||||
final showAuthor =
|
||||
!message.isSystem &&
|
||||
(prevMessage == null ||
|
||||
(message.hasAttachments ||
|
||||
prevMessage == null ||
|
||||
prevMessage.isSystem ||
|
||||
showDayDivider ||
|
||||
prevMessage.pubkey.toLowerCase() !=
|
||||
|
||||
72
mobile/lib/features/channels/media_viewer_hero.dart
Normal file
72
mobile/lib/features/channels/media_viewer_hero.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
293
mobile/lib/features/channels/message_content/media_carousel.dart
Normal file
293
mobile/lib/features/channels/message_content/media_carousel.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||

|
||||

|
||||

|
||||
''',
|
||||
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:
|
||||
'''
|
||||

|
||||

|
||||
''',
|
||||
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) => '').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',
|
||||
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',
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user