Files
org.parres.whitenoise/lib/widgets/media_modal.dart
T

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(),
);
}
}