409 lines
14 KiB
Dart
409 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:whitenoise/hooks/use_media_download.dart';
|
|
import 'package:whitenoise/hooks/use_share_message.dart';
|
|
import 'package:whitenoise/hooks/use_system_notice.dart';
|
|
import 'package:whitenoise/l10n/l10n.dart';
|
|
import 'package:whitenoise/providers/locale_provider.dart';
|
|
import 'package:whitenoise/src/rust/api/media_files.dart';
|
|
import 'package:whitenoise/theme.dart';
|
|
import 'package:whitenoise/utils/aspect_ratio.dart';
|
|
import 'package:whitenoise/utils/media_type.dart';
|
|
import 'package:whitenoise/widgets/chat_media_thumbnail.dart';
|
|
import 'package:whitenoise/widgets/media_image.dart';
|
|
import 'package:whitenoise/widgets/media_video.dart';
|
|
import 'package:whitenoise/widgets/wn_avatar.dart';
|
|
import 'package:whitenoise/widgets/wn_icon.dart';
|
|
import 'package:whitenoise/widgets/wn_icon_button.dart';
|
|
import 'package:whitenoise/widgets/wn_overlay.dart';
|
|
import 'package:whitenoise/widgets/wn_slate.dart';
|
|
import 'package:whitenoise/widgets/wn_system_notice.dart';
|
|
|
|
class MediaModal extends HookWidget {
|
|
final List<MediaFile> mediaFiles;
|
|
final int initialIndex;
|
|
final String? senderName;
|
|
final String? senderPictureUrl;
|
|
final String? senderPubkey;
|
|
final DateTime? timestamp;
|
|
|
|
const MediaModal({
|
|
super.key,
|
|
required this.mediaFiles,
|
|
this.initialIndex = 0,
|
|
this.senderName,
|
|
this.senderPictureUrl,
|
|
this.senderPubkey,
|
|
this.timestamp,
|
|
});
|
|
|
|
static Future<void> show({
|
|
required BuildContext context,
|
|
required List<MediaFile> mediaFiles,
|
|
int initialIndex = 0,
|
|
String? senderName,
|
|
String? senderPictureUrl,
|
|
String? senderPubkey,
|
|
DateTime? timestamp,
|
|
}) {
|
|
return showDialog<void>(
|
|
context: context,
|
|
useSafeArea: false,
|
|
barrierColor: Colors.transparent,
|
|
builder: (_) => MediaModal(
|
|
mediaFiles: mediaFiles,
|
|
initialIndex: initialIndex,
|
|
senderName: senderName,
|
|
senderPictureUrl: senderPictureUrl,
|
|
senderPubkey: senderPubkey,
|
|
timestamp: timestamp,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final currentIndex = useState(initialIndex);
|
|
final isFullscreen = useState(false);
|
|
final isZoomed = useState(false);
|
|
final pageController = useMemoized(
|
|
() => PageController(initialPage: initialIndex),
|
|
);
|
|
|
|
useEffect(() => pageController.dispose, [pageController]);
|
|
|
|
final showOverlays = !isFullscreen.value && !isZoomed.value;
|
|
|
|
final (:noticeMessage, :noticeType, showSuccessNotice: _, :showErrorNotice, :dismissNotice) =
|
|
useSystemNotice();
|
|
|
|
return Scaffold(
|
|
backgroundColor: Colors.transparent,
|
|
body: Stack(
|
|
children: [
|
|
const WnOverlay(variant: WnOverlayVariant.light),
|
|
SafeArea(
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 8.h),
|
|
child: SizedBox(
|
|
height:
|
|
MediaQuery.sizeOf(context).height -
|
|
MediaQuery.paddingOf(context).top -
|
|
MediaQuery.paddingOf(context).bottom -
|
|
16.h,
|
|
child: WnSlate(
|
|
key: const Key('media_modal_slate'),
|
|
animateContent: false,
|
|
systemNotice: noticeMessage != null
|
|
? WnSystemNotice(
|
|
key: ValueKey(noticeMessage),
|
|
title: noticeMessage,
|
|
type: noticeType,
|
|
onDismiss: dismissNotice,
|
|
)
|
|
: null,
|
|
header: AnimatedSize(
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeInOut,
|
|
child: showOverlays
|
|
? _MediaModalHeader(
|
|
senderName: senderName,
|
|
senderPictureUrl: senderPictureUrl,
|
|
senderPubkey: senderPubkey,
|
|
timestamp: timestamp,
|
|
onClose: () => Navigator.of(context).pop(),
|
|
)
|
|
: const SizedBox.shrink(),
|
|
),
|
|
child: _MediaContent(
|
|
mediaFiles: mediaFiles,
|
|
pageController: pageController,
|
|
isZoomed: isZoomed.value,
|
|
showOverlays: showOverlays,
|
|
currentIndex: currentIndex.value,
|
|
onTap: () {
|
|
if (!isZoomed.value) isFullscreen.value = !isFullscreen.value;
|
|
},
|
|
onZoomChanged: (zoomed) => isZoomed.value = zoomed,
|
|
onPageChanged: (index) => currentIndex.value = index,
|
|
onThumbnailTap: (index) {
|
|
pageController.animateToPage(
|
|
index,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
);
|
|
},
|
|
onSaveError: (message) => showErrorNotice(message),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MediaContent extends HookWidget {
|
|
final List<MediaFile> mediaFiles;
|
|
final PageController pageController;
|
|
final bool isZoomed;
|
|
final bool showOverlays;
|
|
final int currentIndex;
|
|
final VoidCallback onTap;
|
|
final ValueChanged<bool> onZoomChanged;
|
|
final ValueChanged<int> onPageChanged;
|
|
final ValueChanged<int> onThumbnailTap;
|
|
final ValueChanged<String> onSaveError;
|
|
|
|
const _MediaContent({
|
|
required this.mediaFiles,
|
|
required this.pageController,
|
|
required this.isZoomed,
|
|
required this.showOverlays,
|
|
required this.currentIndex,
|
|
required this.onTap,
|
|
required this.onZoomChanged,
|
|
required this.onPageChanged,
|
|
required this.onThumbnailTap,
|
|
required this.onSaveError,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final currentFile = mediaFiles[currentIndex];
|
|
final mediaDownload = useMediaDownload(mediaFile: currentFile);
|
|
final localPath = mediaDownload.status == MediaDownloadStatus.success
|
|
? mediaDownload.localPath
|
|
: null;
|
|
final shareMedia = useShareMessage(
|
|
filePaths: localPath != null ? [localPath] : const [],
|
|
onError: (_) => onSaveError(l10n.shareError),
|
|
);
|
|
|
|
Future<void> shareCurrent() async {
|
|
final shareAction = shareMedia.share;
|
|
if (shareAction == null) return;
|
|
final renderBox = context.findRenderObject() as RenderBox?;
|
|
final sharePositionOrigin = renderBox != null && renderBox.hasSize
|
|
? renderBox.localToGlobal(Offset.zero) & renderBox.size
|
|
: null;
|
|
await shareAction(sharePositionOrigin: sharePositionOrigin);
|
|
}
|
|
|
|
return GestureDetector(
|
|
key: const Key('media_content_tap_area'),
|
|
onTap: onTap,
|
|
onLongPress: localPath != null ? shareCurrent : null,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: PageView.builder(
|
|
key: const Key('media_page_view'),
|
|
controller: pageController,
|
|
itemCount: mediaFiles.length,
|
|
physics: isZoomed ? const NeverScrollableScrollPhysics() : const PageScrollPhysics(),
|
|
onPageChanged: onPageChanged,
|
|
itemBuilder: (_, index) {
|
|
final mediaFile = mediaFiles[index];
|
|
final aspectRatio = getAspectRatioFromDimensions(
|
|
mediaFile.fileMetadata?.dimensions,
|
|
);
|
|
|
|
final shareButton = WnIconButton(
|
|
key: Key('media_modal_share_button_$index'),
|
|
icon: WnIcons.share,
|
|
onPressed: localPath != null ? shareCurrent : null,
|
|
type: WnIconButtonType.outline,
|
|
);
|
|
|
|
if (isVideoMediaFile(mediaFile)) {
|
|
return MediaVideo(
|
|
key: Key('media_video_$index'),
|
|
mediaFile: mediaFile,
|
|
overlay: index == currentIndex && showOverlays ? shareButton : null,
|
|
);
|
|
}
|
|
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Positioned.fill(
|
|
child: MediaImage(
|
|
key: Key('media_image_$index'),
|
|
mediaFile: mediaFile,
|
|
onZoomChanged: onZoomChanged,
|
|
),
|
|
),
|
|
if (index == currentIndex && showOverlays)
|
|
if (aspectRatio != null)
|
|
Positioned.fill(
|
|
child: Center(
|
|
child: AspectRatio(
|
|
aspectRatio: aspectRatio,
|
|
child: Stack(
|
|
children: [
|
|
Positioned(
|
|
top: 12.h,
|
|
right: 12.w,
|
|
child: shareButton,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
)
|
|
else
|
|
Positioned(
|
|
top: 12.h,
|
|
right: 12.w,
|
|
child: shareButton,
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
if (mediaFiles.length > 1)
|
|
_ThumbnailStrip(
|
|
key: const Key('media_thumbnail_strip'),
|
|
visible: showOverlays,
|
|
mediaFiles: mediaFiles,
|
|
currentIndex: currentIndex,
|
|
onThumbnailTap: onThumbnailTap,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MediaModalHeader extends ConsumerWidget {
|
|
final String? senderName;
|
|
final String? senderPictureUrl;
|
|
final String? senderPubkey;
|
|
final DateTime? timestamp;
|
|
final VoidCallback onClose;
|
|
|
|
const _MediaModalHeader({
|
|
this.senderName,
|
|
this.senderPictureUrl,
|
|
this.senderPubkey,
|
|
this.timestamp,
|
|
required this.onClose,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final colors = context.colors;
|
|
final typography = context.typographyScaled;
|
|
final formatters = ref.watch(localeFormattersProvider);
|
|
|
|
return SizedBox(
|
|
height: 80.h,
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
height: 80.h,
|
|
padding: EdgeInsets.fromLTRB(16.w, 0, 12.w, 0),
|
|
alignment: Alignment.center,
|
|
child: WnAvatar(
|
|
pictureUrl: senderPictureUrl,
|
|
displayName: senderName,
|
|
color: senderPubkey != null
|
|
? AvatarColor.fromPubkey(senderPubkey!)
|
|
: AvatarColor.neutral,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
senderName ?? context.l10n.unknownUser,
|
|
key: const Key('media_modal_sender_name'),
|
|
style: typography.semiBold14.copyWith(
|
|
color: colors.backgroundContentPrimary,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (timestamp != null)
|
|
Text(
|
|
formatters.formatRelativeTime(timestamp!, context.l10n),
|
|
key: const Key('media_modal_timestamp'),
|
|
style: typography.medium12.copyWith(
|
|
color: colors.backgroundContentTertiary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
GestureDetector(
|
|
key: const Key('media_modal_close'),
|
|
onTap: onClose,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Container(
|
|
height: 80.h,
|
|
padding: EdgeInsets.only(left: 16.w, right: 24.w),
|
|
alignment: Alignment.center,
|
|
child: WnIcon(
|
|
WnIcons.closeLarge,
|
|
color: colors.backgroundContentPrimary,
|
|
size: 24.sp,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ThumbnailStrip extends StatelessWidget {
|
|
final bool visible;
|
|
final List<MediaFile> mediaFiles;
|
|
final int currentIndex;
|
|
final ValueChanged<int> onThumbnailTap;
|
|
|
|
const _ThumbnailStrip({
|
|
super.key,
|
|
required this.visible,
|
|
required this.mediaFiles,
|
|
required this.currentIndex,
|
|
required this.onThumbnailTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedSize(
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeInOut,
|
|
child: visible
|
|
? SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h),
|
|
child: Row(
|
|
children: List.generate(mediaFiles.length, (index) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(right: index < mediaFiles.length - 1 ? 8.w : 0),
|
|
child: ChatMediaThumbnail(
|
|
key: Key('thumbnail_$index'),
|
|
mediaFile: mediaFiles[index],
|
|
isSelected: index == currentIndex,
|
|
onTap: () => onThumbnailTap(index),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
)
|
|
: const SizedBox.shrink(),
|
|
);
|
|
}
|
|
}
|