// ignore_for_file: inference_failure_on_collection_literal, avoid_dynamic_calls import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:drift/drift.dart' hide Column; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:lottie/lottie.dart'; import 'package:no_screenshot/no_screenshot.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/daos/contacts_dao.dart'; import 'package:twonly/src/database/tables/messages_table.dart'; import 'package:twonly/src/database/twonly_database.dart'; import 'package:twonly/src/model/json/message.dart'; import 'package:twonly/src/model/protobuf/push_notification/push_notification.pb.dart'; import 'package:twonly/src/services/api/media_download.dart'; import 'package:twonly/src/services/api/messages.dart'; import 'package:twonly/src/services/api/utils.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/views/camera/camera_send_to_view.dart'; import 'package:twonly/src/views/camera/share_image_editor_view.dart'; import 'package:twonly/src/views/components/animate_icon.dart'; import 'package:twonly/src/views/components/media_view_sizing.dart'; import 'package:video_player/video_player.dart'; final NoScreenshot _noScreenshot = NoScreenshot.instance; class MediaViewerView extends StatefulWidget { const MediaViewerView(this.contact, {super.key, this.initialMessage}); final Contact contact; final Message? initialMessage; @override State createState() => _MediaViewerViewState(); } class _MediaViewerViewState extends State { Timer? nextMediaTimer; Timer? progressTimer; bool showShortReactions = false; double mediaViewerDistanceFromBottom = 0; // current image related Uint8List? imageBytes; String? videoPath; VideoPlayerController? videoController; DateTime? canBeSeenUntil; int maxShowTime = 999999; double progress = 0; bool isRealTwonly = false; bool mirrorVideo = false; bool isDownloading = false; bool showSendTextMessageInput = false; final GlobalKey mediaWidgetKey = GlobalKey(); bool imageSaved = false; bool imageSaving = false; StreamSubscription? downloadStateListener; List allMediaFiles = []; late StreamSubscription> _subscription; TextEditingController textMessageController = TextEditingController(); @override void initState() { super.initState(); if (widget.initialMessage != null) { allMediaFiles = [widget.initialMessage!]; } asyncLoadNextMedia(true); } @override void dispose() { nextMediaTimer?.cancel(); progressTimer?.cancel(); _noScreenshot.screenshotOn(); _subscription.cancel(); downloadStateListener?.cancel(); videoController?.dispose(); super.dispose(); } Future asyncLoadNextMedia(bool firstRun) async { final messages = twonlyDB.messagesDao.watchMediaMessageNotOpened(widget.contact.userId); _subscription = messages.listen((messages) { for (final msg in messages) { // if (!allMediaFiles.any((m) => m.messageId == msg.messageId)) { // allMediaFiles.add(msg); // } // Find the index of the existing message with the same messageId final index = allMediaFiles.indexWhere((m) => m.messageId == msg.messageId); if (index >= 1) { // to not modify the first message // If the message exists, replace it allMediaFiles[index] = msg; } else if (index == -1) { // If the message does not exist, add it allMediaFiles.add(msg); } } setState(() {}); if (firstRun) { loadCurrentMediaFile(); // ignore: parameter_assignments firstRun = false; } }); } Future nextMediaOrExit() async { if (!mounted) return; await videoController?.dispose(); nextMediaTimer?.cancel(); progressTimer?.cancel(); if (allMediaFiles.isNotEmpty) { try { if (!imageSaved && maxShowTime != gMediaShowInfinite) { await deleteMediaFile(allMediaFiles.first.messageId, 'mp4'); await deleteMediaFile(allMediaFiles.first.messageId, 'png'); } } catch (e) { Log.error('$e'); } } if (allMediaFiles.isEmpty || allMediaFiles.length == 1) { if (mounted) { Navigator.pop(context); } } else { allMediaFiles.removeAt(0); await loadCurrentMediaFile(); } } Future loadCurrentMediaFile({bool showTwonly = false}) async { if (!mounted) return; if (!context.mounted || allMediaFiles.isEmpty) return nextMediaOrExit(); await _noScreenshot.screenshotOff(); setState(() { videoController = null; imageBytes = null; canBeSeenUntil = null; maxShowTime = 999999; imageSaving = false; imageSaved = false; mirrorVideo = false; progress = 0; videoPath = null; isDownloading = false; isRealTwonly = false; showSendTextMessageInput = false; }); if (Platform.isAndroid) { await flutterLocalNotificationsPlugin .cancel(allMediaFiles.first.contactId); } else { await flutterLocalNotificationsPlugin.cancelAll(); } if (allMediaFiles.first.downloadState != DownloadState.downloaded) { setState(() { isDownloading = true; }); await startDownloadMedia(allMediaFiles.first, true); final stream = twonlyDB.messagesDao .getMessageByMessageId(allMediaFiles.first.messageId) .watchSingleOrNull(); await downloadStateListener?.cancel(); downloadStateListener = stream.listen((updated) async { if (updated != null) { if (updated.downloadState == DownloadState.downloaded) { await downloadStateListener?.cancel(); await handleNextDownloadedMedia(updated, showTwonly); // start downloading all the other possible missing media files. await tryDownloadAllMediaFiles(force: true); } } }); } else { await handleNextDownloadedMedia(allMediaFiles.first, showTwonly); } } Future handleNextDownloadedMedia( Message current, bool showTwonly) async { final content = MediaMessageContent.fromJson(jsonDecode(current.contentJson!) as Map); if (content.isRealTwonly) { setState(() { isRealTwonly = true; }); if (!showTwonly) return; final isAuth = await authenticateUser( context.lang.mediaViewerAuthReason, force: false, ); if (!isAuth) { await nextMediaOrExit(); return; } } await notifyContactAboutOpeningMessage( current.contactId, [current.messageOtherId!], ); await twonlyDB.messagesDao.updateMessageByMessageId( current.messageId, MessagesCompanion(openedAt: Value(DateTime.now())), ); if (content.isVideo) { final videoPathTmp = await getVideoPath(current.messageId); if (videoPathTmp != null) { videoController = VideoPlayerController.file(File(videoPathTmp.path)); await videoController ?.setLooping(content.maxShowTime == gMediaShowInfinite); await videoController?.initialize().then((_) { videoController!.play(); videoController?.addListener(() { setState(() { progress = 1 - videoController!.value.position.inSeconds / videoController!.value.duration.inSeconds; }); if (content.maxShowTime != gMediaShowInfinite) { if (videoController?.value.position == videoController?.value.duration) { nextMediaOrExit(); } } }); setState(() { videoPath = videoPathTmp.path; }); // ignore: invalid_return_type_for_catch_error, argument_type_not_assignable_to_error_handler }).catchError(Log.error); } } imageBytes = await getImageBytes(current.messageId); if ((imageBytes == null && !content.isVideo) || (content.isVideo && videoController == null)) { Log.error('media files are not found...'); // When the message should be downloaded but imageBytes are null then a error happened await handleMediaError(current); return nextMediaOrExit(); } if (!content.isVideo) { if (content.maxShowTime != gMediaShowInfinite) { canBeSeenUntil = DateTime.now().add( Duration(seconds: content.maxShowTime), ); startTimer(); } } setState(() { maxShowTime = content.maxShowTime; isDownloading = false; mirrorVideo = content.mirrorVideo; }); } void startTimer() { nextMediaTimer?.cancel(); progressTimer?.cancel(); nextMediaTimer = Timer(canBeSeenUntil!.difference(DateTime.now()), () { if (context.mounted) { nextMediaOrExit(); } }); progressTimer = Timer.periodic(const Duration(milliseconds: 10), (timer) { if (canBeSeenUntil != null) { final difference = canBeSeenUntil!.difference(DateTime.now()); // Calculate the progress as a value between 0.0 and 1.0 progress = difference.inMilliseconds / (maxShowTime * 1000); setState(() {}); } }); } Future onPressedSaveToGallery() async { if (allMediaFiles.first.messageOtherId == null) { return; // should not be possible } setState(() { imageSaving = true; }); await twonlyDB.messagesDao.updateMessageByMessageId( allMediaFiles.first.messageId, const MessagesCompanion(mediaStored: Value(true)), ); await encryptAndSendMessageAsync( null, widget.contact.userId, MessageJson( kind: MessageKind.storedMediaFile, messageSenderId: allMediaFiles.first.messageId, messageReceiverId: allMediaFiles.first.messageOtherId, content: MessageContent(), timestamp: DateTime.now(), ), pushNotification: PushNotification(kind: PushKind.storedMediaFile), ); setState(() { imageSaved = true; }); final user = await getUser(); if (user != null && (user.storeMediaFilesInGallery)) { if (videoPath != null) { await saveVideoToGallery(videoPath!); } else { await saveImageToGallery(imageBytes!); } } setState(() { imageSaving = false; }); } void displayShortReactions() { final renderBox = mediaWidgetKey.currentContext!.findRenderObject()! as RenderBox; setState(() { showShortReactions = true; mediaViewerDistanceFromBottom = renderBox.size.height; }); } Widget bottomNavigation() { return Row( key: mediaWidgetKey, mainAxisAlignment: MainAxisAlignment.center, children: [ if (maxShowTime == gMediaShowInfinite) OutlinedButton( style: OutlinedButton.styleFrom( iconColor: imageSaved ? Theme.of(context).colorScheme.outline : Theme.of(context).colorScheme.primary, foregroundColor: imageSaved ? Theme.of(context).colorScheme.outline : Theme.of(context).colorScheme.primary, ), onPressed: onPressedSaveToGallery, child: Row( children: [ if (imageSaving) const SizedBox( width: 10, height: 10, child: CircularProgressIndicator(strokeWidth: 1)) else imageSaved ? const Icon(Icons.check) : const FaIcon(FontAwesomeIcons.floppyDisk), ], ), ), const SizedBox(width: 10), IconButton( icon: SizedBox( width: 30, height: 30, child: GridView.count( crossAxisCount: 2, children: List.generate( 4, (index) { return SizedBox( width: 8, height: 8, child: Center( child: EmojiAnimation( emoji: EmojiAnimation.animatedIcons.keys.toList()[index], ), ), ); }, ), ), ), onPressed: () async { if (!showShortReactions) { displayShortReactions(); } else { setState(() { showShortReactions = false; }); } }, style: ButtonStyle( padding: WidgetStateProperty.all( const EdgeInsets.symmetric(vertical: 10, horizontal: 20), ), ), ), const SizedBox(width: 10), IconButton.outlined( icon: const FaIcon(FontAwesomeIcons.message), onPressed: () async { displayShortReactions(); setState(() { showSendTextMessageInput = true; }); }, style: ButtonStyle( padding: WidgetStateProperty.all( const EdgeInsets.symmetric(vertical: 10, horizontal: 20), ), ), ), const SizedBox(width: 10), IconButton.outlined( icon: const FaIcon(FontAwesomeIcons.camera), onPressed: () async { nextMediaTimer?.cancel(); progressTimer?.cancel(); await videoController?.pause(); if (!mounted) return; await Navigator.push(context, MaterialPageRoute( builder: (context) { return CameraSendToView(widget.contact); }, )); if (mounted && maxShowTime != gMediaShowInfinite) { await nextMediaOrExit(); } else { await videoController?.play(); } }, style: ButtonStyle( padding: WidgetStateProperty.all( const EdgeInsets.symmetric(vertical: 10, horizontal: 20), ), ), ), ], ); } @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: Stack( fit: StackFit.expand, children: [ if ((imageBytes != null || videoController != null) && (canBeSeenUntil == null || progress >= 0)) GestureDetector( onTap: () { if (showSendTextMessageInput) { setState(() { showShortReactions = false; showSendTextMessageInput = false; }); return; } nextMediaOrExit(); }, child: MediaViewSizing( bottomNavigation: bottomNavigation(), requiredHeight: 90, child: Stack( children: [ if (videoController != null) Positioned.fill( child: Transform.flip( flipX: mirrorVideo, child: VideoPlayer(videoController!), ), ), if (imageBytes != null) Positioned.fill( child: Image.memory( imageBytes!, fit: BoxFit.contain, frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { if (wasSynchronouslyLoaded) return child; return AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: frame != null ? child : Container( height: 60, color: Colors.transparent, width: 60, child: const CircularProgressIndicator( strokeWidth: 2, ), ), ); }, ), ), ], ), ), ), if (isRealTwonly && imageBytes == null) Positioned.fill( child: GestureDetector( onTap: () { loadCurrentMediaFile(showTwonly: true); }, child: Column( children: [ Expanded( child: Lottie.asset( 'assets/animations/present.lottie.json', ), ), Container( padding: const EdgeInsets.only(bottom: 200), child: Text(context.lang.mediaViewerTwonlyTapToOpen), ), ], ), ), ), Positioned( left: 10, top: 10, child: Row( children: [ IconButton( icon: const Icon(Icons.close, size: 30), color: Colors.white, onPressed: () async { Navigator.pop(context); }, ), ], ), ), if (isDownloading) const Positioned.fill( child: Center( child: SizedBox( height: 60, width: 60, child: CircularProgressIndicator(strokeWidth: 6), ), ), ), if (canBeSeenUntil != null || progress >= 0) Positioned( right: 20, top: 27, child: Row( children: [ SizedBox( width: 20, height: 20, child: CircularProgressIndicator( value: progress, strokeWidth: 2, ), ), ], ), ), Positioned( top: 10, left: showSendTextMessageInput ? 0 : null, right: showSendTextMessageInput ? 0 : 15, child: Text( getContactDisplayName(widget.contact), textAlign: TextAlign.center, style: TextStyle( fontSize: showSendTextMessageInput ? 24 : 14, fontWeight: FontWeight.bold, color: showSendTextMessageInput ? null : const Color.fromARGB(255, 126, 126, 126), shadows: const [ Shadow( color: Color.fromARGB(122, 0, 0, 0), blurRadius: 5, ) ], ), ), ), if (showSendTextMessageInput) Positioned( bottom: 0, left: 0, right: 0, child: Container( color: context.color.surface, padding: const EdgeInsets.only( bottom: 10, left: 20, right: 20, top: 10), child: Row( children: [ IconButton( icon: const FaIcon(FontAwesomeIcons.xmark), onPressed: () { setState(() { showShortReactions = false; showSendTextMessageInput = false; }); }, ), Expanded( child: TextField( autofocus: true, controller: textMessageController, onEditingComplete: () { setState(() { showSendTextMessageInput = false; showShortReactions = false; }); }, decoration: inputTextMessageDeco(context), ), ), IconButton( icon: const FaIcon(FontAwesomeIcons.solidPaperPlane), onPressed: () { if (textMessageController.text.isNotEmpty) { sendTextMessage( widget.contact.userId, TextMessageContent( text: textMessageController.text, responseToMessageId: allMediaFiles.first.messageOtherId, ), PushNotification( kind: PushKind.response, ), ); textMessageController.clear(); } setState(() { showSendTextMessageInput = false; showShortReactions = false; }); }, ) ], ), ), ), if (allMediaFiles.isNotEmpty) ReactionButtons( show: showShortReactions, textInputFocused: showSendTextMessageInput, mediaViewerDistanceFromBottom: mediaViewerDistanceFromBottom, userId: widget.contact.userId, responseToMessageId: allMediaFiles.first.messageOtherId!, isVideo: videoController != null, hide: () { setState(() { showShortReactions = false; showSendTextMessageInput = false; }); }, ), ], ), ), ); } } class ReactionButtons extends StatefulWidget { const ReactionButtons({ required this.show, required this.textInputFocused, required this.userId, required this.mediaViewerDistanceFromBottom, required this.responseToMessageId, required this.isVideo, required this.hide, super.key, }); final double mediaViewerDistanceFromBottom; final bool show; final bool isVideo; final bool textInputFocused; final int userId; final int responseToMessageId; final void Function() hide; @override State createState() => _ReactionButtonsState(); } class _ReactionButtonsState extends State { int selectedShortReaction = -1; List selectedEmojis = EmojiAnimation.animatedIcons.keys.toList().sublist(0, 6); @override void initState() { super.initState(); initAsync(); } Future initAsync() async { final user = await getUser(); if (user != null && user.preSelectedEmojies != null) { selectedEmojis = user.preSelectedEmojies!; } setState(() {}); } @override Widget build(BuildContext context) { final firstRowEmojis = selectedEmojis.take(6).toList(); final secondRowEmojis = selectedEmojis.length > 6 ? selectedEmojis.skip(6).toList() : []; return AnimatedPositioned( duration: const Duration(milliseconds: 200), // Animation duration bottom: widget.show ? (widget.textInputFocused ? 50 : widget.mediaViewerDistanceFromBottom) : widget.mediaViewerDistanceFromBottom - 20, left: widget.show ? 0 : MediaQuery.sizeOf(context).width / 2, right: widget.show ? 0 : MediaQuery.sizeOf(context).width / 2, curve: Curves.linearToEaseOut, child: AnimatedOpacity( opacity: widget.show ? 1.0 : 0.0, // Fade in/out duration: const Duration(milliseconds: 150), child: Container( color: widget.show ? Colors.black.withAlpha(0) : Colors.transparent, padding: widget.show ? const EdgeInsets.symmetric(vertical: 32) : null, child: Column( children: [ if (secondRowEmojis.isNotEmpty) Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.end, children: secondRowEmojis .map((emoji) => EmojiReactionWidget( userId: widget.userId, responseToMessageId: widget.responseToMessageId, hide: widget.hide, show: widget.show, isVideo: widget.isVideo, emoji: emoji as String, )) .toList(), ), if (secondRowEmojis.isNotEmpty) const SizedBox(height: 15), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.end, children: firstRowEmojis .map( (emoji) => EmojiReactionWidget( userId: widget.userId, responseToMessageId: widget.responseToMessageId, hide: widget.hide, show: widget.show, isVideo: widget.isVideo, emoji: emoji, ), ) .toList(), ), ], ), ), ), ); } } class EmojiReactionWidget extends StatefulWidget { const EmojiReactionWidget({ required this.userId, required this.responseToMessageId, required this.hide, required this.isVideo, required this.show, required this.emoji, super.key, }); final int userId; final int responseToMessageId; final Function hide; final bool show; final bool isVideo; final String emoji; @override State createState() => _EmojiReactionWidgetState(); } class _EmojiReactionWidgetState extends State { int selectedShortReaction = -1; @override Widget build(BuildContext context) { return AnimatedSize( duration: const Duration(milliseconds: 200), curve: Curves.linearToEaseOut, child: GestureDetector( onTap: () { sendTextMessage( widget.userId, TextMessageContent( text: widget.emoji, responseToMessageId: widget.responseToMessageId, ), PushNotification( kind: widget.isVideo ? PushKind.reactionToVideo : PushKind.reactionToImage, reactionContent: widget.emoji, ), ); setState(() { selectedShortReaction = 0; // Assuming index is 0 for this example }); Future.delayed(const Duration(milliseconds: 300), () { setState(() { widget.hide(); selectedShortReaction = -1; }); }); }, child: (selectedShortReaction == 0) // Assuming index is 0 for this example ? EmojiAnimationFlying( emoji: widget.emoji, duration: const Duration(milliseconds: 300), startPosition: 0, size: (widget.show) ? 40 : 10, ) : AnimatedOpacity( opacity: (selectedShortReaction == -1) ? 1 : 0, // Fade in/out duration: const Duration(milliseconds: 150), child: SizedBox( width: widget.show ? 40 : 10, child: Center( child: EmojiAnimation( emoji: widget.emoji, ), ), ), ), ), ); } }