From 0007ba62a0339cd91c98a444b85ae5b5ccba98a1 Mon Sep 17 00:00:00 2001 From: otsmr Date: Fri, 19 Jun 2026 16:26:12 +0200 Subject: [PATCH] split up the media viewer into statless widgets --- .../visual/views/chats/media_viewer.view.dart | 459 +++++++----------- .../media_content_renderer.comp.dart | 65 +++ .../media_viewer_message_input.comp.dart | 58 +++ .../reaction_buttons.comp.dart | 38 +- .../twonly_present_overlay.comp.dart | 36 ++ 5 files changed, 369 insertions(+), 287 deletions(-) create mode 100644 lib/src/visual/views/chats/media_viewer_components/media_content_renderer.comp.dart create mode 100644 lib/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart create mode 100644 lib/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart diff --git a/lib/src/visual/views/chats/media_viewer.view.dart b/lib/src/visual/views/chats/media_viewer.view.dart index dee948f2..b04a8fe4 100644 --- a/lib/src/visual/views/chats/media_viewer.view.dart +++ b/lib/src/visual/views/chats/media_viewer.view.dart @@ -1,15 +1,12 @@ import 'dart:async'; import 'dart:collection'; -import 'dart:math'; import 'package:clock/clock.dart'; import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; -import 'package:lottie/lottie.dart'; import 'package:mutex/mutex.dart'; -import 'package:photo_view/photo_view.dart'; import 'package:screen_protector/screen_protector.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; @@ -29,12 +26,14 @@ import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/animate_icon.comp.dart'; import 'package:twonly/src/visual/elements/my_icon_button.element.dart'; -import 'package:twonly/src/visual/elements/my_input.element.dart'; import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart'; import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart'; import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/additional_message_content.dart'; +import 'package:twonly/src/visual/views/chats/media_viewer_components/media_content_renderer.comp.dart'; +import 'package:twonly/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart'; +import 'package:twonly/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart'; import 'package:video_player/video_player.dart'; class MediaViewerView extends StatefulWidget { @@ -64,6 +63,8 @@ class _MediaViewerViewState extends State { DateTime? canBeSeenUntil; final ValueNotifier progress = ValueNotifier(0); bool showSendTextMessageInput = false; + double maxBottomInset = 0; + DateTime? _lastTimeInputClosed; final GlobalKey mediaWidgetKey = GlobalKey(); bool imageSaved = false; @@ -82,9 +83,6 @@ class _MediaViewerViewState extends State { final HashSet _alreadyOpenedMediaIds = HashSet(); bool _isTransitioning = false; - bool _isZoomed = false; - late PageController _verticalPager; - final ValueNotifier _backdropOpacityNotifier = ValueNotifier(1); @override void initState() { @@ -95,12 +93,7 @@ class _MediaViewerViewState extends State { allMediaFiles = [widget.initialMessage!]; } - _verticalPager = PageController(initialPage: 1); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _verticalPager.addListener(_onVerticalScrollUpdated); - }); - - asyncLoadNextMedia(true); + listenForUnopenedMedia(true); } @override @@ -127,21 +120,9 @@ class _MediaViewerViewState extends State { ); textMessageController.dispose(); - _verticalPager - ..removeListener(_onVerticalScrollUpdated) - ..dispose(); - _backdropOpacityNotifier.dispose(); - super.dispose(); } - void _onVerticalScrollUpdated() { - if (!_verticalPager.hasClients) return; - final page = _verticalPager.page ?? 1.0; - final linearFraction = min(1, max(0, page)).toDouble(); - _backdropOpacityNotifier.value = linearFraction * linearFraction; - } - void _disposeVideoController() { final listener = _videoListener; final controller = videoController; @@ -160,7 +141,7 @@ class _MediaViewerViewState extends State { (ModalRoute.of(context)?.isCurrent ?? false); } - Future asyncLoadNextMedia(bool firstRun) async { + Future listenForUnopenedMedia(bool firstRun) async { _subscription = twonlyDB.messagesDao .watchMediaNotOpened(widget.group.groupId) .listen((messages) async { @@ -174,7 +155,7 @@ class _MediaViewerViewState extends State { } if (msg.mediaId == currentMedia?.mediaFile.mediaId) { - // The update of the current Media in case of a download is done in loadCurrentMediaFile + // The update of the current Media in case of a download is done in loadAndDownloadCurrentMedia continue; } @@ -207,13 +188,13 @@ class _MediaViewerViewState extends State { if (mounted) setState(() {}); if (firstRun) { firstRun = false; - await loadCurrentMediaFile(); + await loadAndDownloadCurrentMedia(); } }); }); } - Future nextMediaOrExit() async { + Future advanceToNextMediaOrExit() async { if (_isTransitioning) return; _isTransitioning = true; @@ -245,17 +226,17 @@ class _MediaViewerViewState extends State { } } } else { - await loadCurrentMediaFile(); + await loadAndDownloadCurrentMedia(); } } finally { if (mounted) _isTransitioning = false; } } - Future loadCurrentMediaFile({bool showTwonly = false}) async { + Future loadAndDownloadCurrentMedia({bool showTwonly = false}) async { if (!mounted || !context.mounted) return; if (allMediaFiles.isEmpty || allMediaFiles.first.mediaId == null) { - return nextMediaOrExit(); + return advanceToNextMediaOrExit(); } try { @@ -293,7 +274,7 @@ class _MediaViewerViewState extends State { // Media file record no longer exists — skip to next or exit rather // than leaving the screen permanently black with no content/loader. await downloadStateListener?.cancel(); - await nextMediaOrExit(); + await advanceToNextMediaOrExit(); return; } if (updated.downloadState != DownloadState.ready) { @@ -308,7 +289,7 @@ class _MediaViewerViewState extends State { if (mediaFile == null) { // DB record gone — skip to next or exit. await downloadStateListener?.cancel(); - await nextMediaOrExit(); + await advanceToNextMediaOrExit(); return; } await startDownloadMedia(mediaFile, true); @@ -319,18 +300,16 @@ class _MediaViewerViewState extends State { await downloadStateListener?.cancel(); try { - await handleNextDownloadedMedia(showTwonly); + await initializeAndDisplayCurrentMedia(showTwonly); } catch (e, st) { - Log.error('handleNextDownloadedMedia failed: $e\n$st'); - await nextMediaOrExit(); + Log.error('initializeAndDisplayCurrentMedia failed: $e\n$st'); + await advanceToNextMediaOrExit(); } // start downloading all the other possible missing media files. }); } - Future handleNextDownloadedMedia( - bool showTwonly, - ) async { + Future initializeAndDisplayCurrentMedia(bool showTwonly) async { if (allMediaFiles.isEmpty) return; setState(() { _showDownloadingLoader = false; @@ -355,7 +334,7 @@ class _MediaViewerViewState extends State { if (!mounted) return; if (!isAuth) { - await nextMediaOrExit(); + await advanceToNextMediaOrExit(); if (mounted) { setState(() { displayTwonlyPresent = false; @@ -372,25 +351,55 @@ class _MediaViewerViewState extends State { displayTwonlyPresent = false; }); - if (!widget.group.isDirectChat) { - final sender = await twonlyDB.contactsDao.getContactById( - currentMessage!.senderId!, - ); + await _updateSenderInfo(); + if (!mounted) return; - if (!mounted) return; + await _notifyMessageOpened(currentMediaLocal); + if (!mounted) return; - if (sender != null) { - _currentMediaSender = - '${getContactDisplayName(sender)} (${widget.group.groupName})'; - } + if (!currentMediaLocal.tempPath.existsSync()) { + Log.error('Temp media file not found...'); + await handleMediaError(currentMediaLocal.mediaFile); + return advanceToNextMediaOrExit(); } + // The server can now delete the encrypted bytes, as the users has sucessfully opened it. + unawaited( + apiService.downloadDone(currentMediaLocal.mediaFile.downloadToken!), + ); + + if (currentMediaLocal.mediaFile.type == MediaType.video) { + await _setupVideoPlayer(currentMediaLocal); + } else { + _setupImageTimer(currentMediaLocal); + } + + if (mounted) { + setState(() { + currentMedia = currentMediaLocal; + }); + } + } + + Future _updateSenderInfo() async { + if (currentMessage == null || widget.group.isDirectChat) return; + final sender = await twonlyDB.contactsDao.getContactById( + currentMessage!.senderId!, + ); + if (mounted && sender != null) { + _currentMediaSender = + '${getContactDisplayName(sender)} (${widget.group.groupName})'; + } + } + + Future _notifyMessageOpened(MediaFileService mediaLocal) async { + if (currentMessage == null) return; var markAsOpenMessageIDs = [currentMessage!.messageId]; if (userService.currentUser.automaticallyMarkEqualMediaFilesAsOpened && - currentMediaLocal.mediaFile.storedFileHash != null) { + mediaLocal.mediaFile.storedFileHash != null) { final messageIds = await twonlyDB.mediaFilesDao.getMessageIdsByMediaHash( - currentMediaLocal.mediaFile.storedFileHash!, + mediaLocal.mediaFile.storedFileHash!, currentMessage!.senderId!, ); @@ -408,106 +417,82 @@ class _MediaViewerViewState extends State { currentMessage!.senderId!, markAsOpenMessageIDs, ); + } - if (!mounted) return; - - if (!currentMediaLocal.tempPath.existsSync()) { - Log.error('Temp media file not found...'); - await handleMediaError(currentMediaLocal.mediaFile); - return nextMediaOrExit(); - } - - // The server can now delete the encrypted bytes, as the users has sucessfully opened it. - unawaited( - apiService.downloadDone(currentMediaLocal.mediaFile.downloadToken!), + Future _setupVideoPlayer(MediaFileService mediaLocal) async { + final controller = VideoPlayerController.file( + mediaLocal.tempPath, + videoPlayerOptions: VideoPlayerOptions( + mixWithOthers: mediaLocal.mediaFile.displayLimitInMilliseconds == null, + ), ); - var timerRequired = false; + await controller.setLooping( + mediaLocal.mediaFile.displayLimitInMilliseconds == null, + ); - if (currentMediaLocal.mediaFile.type == MediaType.video) { - final controller = VideoPlayerController.file( - currentMediaLocal.tempPath, - videoPlayerOptions: VideoPlayerOptions( - // only mix in case the video can be played multiple times, - // otherwise stop the background music in case the video contains audio - mixWithOthers: - currentMediaLocal.mediaFile.displayLimitInMilliseconds == null, + if (!mounted) { + await controller.dispose(); + return; + } + + await controller + .initialize() + .then((_) { + if (!mounted || videoController != null) { + controller.dispose(); + return; + } + + void listener() { + if (!mounted) return; + final ctrl = videoController; + if (ctrl == null) return; + + final duration = ctrl.value.duration.inSeconds; + if (duration > 0) { + progress.value = 1 - ctrl.value.position.inSeconds / duration; + } + + if (mediaLocal.mediaFile.displayLimitInMilliseconds != null) { + if (ctrl.value.position == ctrl.value.duration) { + advanceToNextMediaOrExit(); + } + } + } + + _videoListener = listener; + videoController = controller; + controller + ..addListener(listener) + ..play(); + }) + .catchError((Object err, StackTrace st) { + Log.error('Video player initialization error', err, st); + return null; + }); + } + + void _setupImageTimer(MediaFileService mediaLocal) { + if (mediaLocal.mediaFile.displayLimitInMilliseconds != null) { + canBeSeenUntil = clock.now().add( + Duration( + milliseconds: mediaLocal.mediaFile.displayLimitInMilliseconds!, ), ); - - await controller.setLooping( - currentMediaLocal.mediaFile.displayLimitInMilliseconds == null, - ); - - if (!mounted) { - await controller.dispose(); - return; - } - - await controller - .initialize() - .then((_) { - if (!mounted || videoController != null) { - controller.dispose(); - return; - } - - void listener() { - if (!mounted) return; - final ctrl = videoController; - if (ctrl == null) return; - - final duration = ctrl.value.duration.inSeconds; - if (duration > 0) { - progress.value = 1 - ctrl.value.position.inSeconds / duration; - } - - if (currentMediaLocal.mediaFile.displayLimitInMilliseconds != - null) { - if (ctrl.value.position == ctrl.value.duration) { - nextMediaOrExit(); - } - } - } - - _videoListener = listener; - videoController = controller; - controller - ..addListener(listener) - ..play(); - }) - // ignore: argument_type_not_assignable_to_error_handler, invalid_return_type_for_catch_error - .catchError(Log.error); - - if (!mounted) return; - } else { - if (currentMediaLocal.mediaFile.displayLimitInMilliseconds != null) { - canBeSeenUntil = clock.now().add( - Duration( - milliseconds: - currentMediaLocal.mediaFile.displayLimitInMilliseconds!, - ), - ); - timerRequired = true; - } - } - if (mounted) { - setState(() { - currentMedia = currentMediaLocal; - }); - if (timerRequired) { - startTimer(); + if (mounted) { + startProgressTimer(); } } } - void startTimer() { + void startProgressTimer() { nextMediaTimer?.cancel(); progressTimer?.cancel(); if (canBeSeenUntil != null) { nextMediaTimer = Timer(canBeSeenUntil!.difference(clock.now()), () { if (context.mounted) { - nextMediaOrExit(); + advanceToNextMediaOrExit(); } }); progressTimer = Timer.periodic(const Duration(milliseconds: 10), (timer) { @@ -653,7 +638,7 @@ class _MediaViewerViewState extends State { ); if (mounted && currentMedia!.mediaFile.displayLimitInMilliseconds != null) { - await nextMediaOrExit(); + await advanceToNextMediaOrExit(); } else { await videoController?.play(); } @@ -677,19 +662,43 @@ class _MediaViewerViewState extends State { ); } - void onTap() { + void onScreenTapped() { + if (_lastTimeInputClosed != null && + clock.now().difference(_lastTimeInputClosed!) < + const Duration(milliseconds: 300)) { + return; + } if (showSendTextMessageInput) { setState(() { showShortReactions = false; showSendTextMessageInput = false; + _lastTimeInputClosed = clock.now(); }); return; } - nextMediaOrExit(); + advanceToNextMediaOrExit(); } @override Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + if (bottomInset > maxBottomInset) { + maxBottomInset = bottomInset; + } else if (bottomInset == 0 && maxBottomInset > 0) { + maxBottomInset = 0; + if (showSendTextMessageInput) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + showSendTextMessageInput = false; + showShortReactions = false; + _lastTimeInputClosed = clock.now(); + }); + } + }); + } + } + return Scaffold( body: SafeArea( child: Stack( @@ -699,93 +708,21 @@ class _MediaViewerViewState extends State { if ((currentMedia != null || videoController != null) && (canBeSeenUntil == null || progress.value >= 0)) GestureDetector( - onTap: onTap, - onDoubleTap: (videoController == null) ? null : onTap, + onTap: onScreenTapped, + onDoubleTap: (videoController == null) ? null : onScreenTapped, child: MediaViewSizingHelper( bottomNavigation: bottomNavigation(), requiredHeight: 55, - child: Stack( - children: [ - if (videoController != null) - Positioned.fill( - child: PhotoView.customChild( - initialScale: PhotoViewComputedScale.contained, - minScale: PhotoViewComputedScale.contained, - backgroundDecoration: const BoxDecoration( - color: Colors.transparent, - ), - scaleStateChangedCallback: (state) { - final zoomed = - state != PhotoViewScaleState.initial; - if (_isZoomed != zoomed) { - setState(() { - _isZoomed = zoomed; - }); - } - }, - child: VideoPlayer( - videoController!, - ), - ), - ) - else if (currentMedia != null && - (currentMedia!.mediaFile.type == MediaType.image || - currentMedia!.mediaFile.type == MediaType.gif)) - Positioned.fill( - child: PhotoView( - imageProvider: FileImage( - currentMedia!.tempPath, - ), - loadingBuilder: (context, event) => _loader(), - backgroundDecoration: const BoxDecoration( - color: Colors.transparent, - ), - initialScale: PhotoViewComputedScale.contained, - minScale: PhotoViewComputedScale.contained, - scaleStateChangedCallback: (state) { - final zoomed = - state != PhotoViewScaleState.initial; - if (_isZoomed != zoomed) { - setState(() { - _isZoomed = zoomed; - }); - } - }, - errorBuilder: (context, error, stackTrace) { - return const Center( - child: Icon( - Icons.broken_image_outlined, - color: Colors.white38, - size: 64, - ), - ); - }, - ), - ), - ], + child: MediaContentRenderer( + currentMedia: currentMedia, + videoController: videoController, + loader: _loader(), ), ), ), if (displayTwonlyPresent) - Positioned.fill( - child: GestureDetector( - onTap: () => loadCurrentMediaFile(showTwonly: true), - child: Column( - children: [ - Expanded( - child: Lottie.asset( - 'assets/animations/present.lottie.lottie', - ), - ), - Container( - padding: const EdgeInsets.only(bottom: 200), - child: Text( - context.lang.mediaViewerTwonlyTapToOpen, - ), - ), - ], - ), - ), + TwonlyPresentOverlay( + onTap: () => loadAndDownloadCurrentMedia(showTwonly: true), ), if (currentMedia != null && currentMedia?.mediaFile.downloadState != DownloadState.ready) @@ -835,63 +772,32 @@ class _MediaViewerViewState extends State { ), ), 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: [ - Expanded( - child: MyInput( - dense: true, - autofocus: true, - controller: textMessageController, - hintText: context.lang.chatListDetailInput, - onChanged: (value) { - setState(() {}); - }, - onSubmitted: (value) { - setState(() { - showSendTextMessageInput = false; - showShortReactions = false; - }); - }, - ), + MediaViewerMessageInput( + controller: textMessageController, + onSubmitted: (value) { + setState(() { + showSendTextMessageInput = false; + showShortReactions = false; + _lastTimeInputClosed = clock.now(); + }); + }, + onSendPressed: () { + if (textMessageController.text.isNotEmpty) { + unawaited( + insertAndSendTextMessage( + widget.group.groupId, + textMessageController.text, + currentMessage!.messageId, ), - const SizedBox(width: 10), - MyIconButton( - icon: const FaIcon( - FontAwesomeIcons.solidPaperPlane, - size: 20, - ), - onPressed: () async { - if (textMessageController.text.isNotEmpty) { - unawaited( - insertAndSendTextMessage( - widget.group.groupId, - textMessageController.text, - currentMessage!.messageId, - ), - ); - textMessageController.clear(); - } - setState(() { - showSendTextMessageInput = false; - showShortReactions = false; - }); - }, - ), - ], - ), - ), + ); + textMessageController.clear(); + } + setState(() { + showSendTextMessageInput = false; + showShortReactions = false; + _lastTimeInputClosed = clock.now(); + }); + }, ), if (currentMessage != null) AdditionalMessageContent(currentMessage!), @@ -907,6 +813,7 @@ class _MediaViewerViewState extends State { setState(() { showShortReactions = false; showSendTextMessageInput = false; + _lastTimeInputClosed = clock.now(); }); }, ), diff --git a/lib/src/visual/views/chats/media_viewer_components/media_content_renderer.comp.dart b/lib/src/visual/views/chats/media_viewer_components/media_content_renderer.comp.dart new file mode 100644 index 00000000..0033f4eb --- /dev/null +++ b/lib/src/visual/views/chats/media_viewer_components/media_content_renderer.comp.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:twonly/src/database/tables/mediafiles.table.dart' + show MediaType; +import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; +import 'package:video_player/video_player.dart'; + +class MediaContentRenderer extends StatelessWidget { + const MediaContentRenderer({ + required this.currentMedia, + required this.videoController, + required this.loader, + super.key, + }); + + final MediaFileService? currentMedia; + final VideoPlayerController? videoController; + final Widget loader; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + if (videoController != null) + Positioned.fill( + child: PhotoView.customChild( + initialScale: PhotoViewComputedScale.contained, + minScale: PhotoViewComputedScale.contained, + backgroundDecoration: const BoxDecoration( + color: Colors.transparent, + ), + child: VideoPlayer( + videoController!, + ), + ), + ) + else if (currentMedia != null && + (currentMedia!.mediaFile.type == MediaType.image || + currentMedia!.mediaFile.type == MediaType.gif)) + Positioned.fill( + child: PhotoView( + imageProvider: FileImage( + currentMedia!.tempPath, + ), + loadingBuilder: (context, event) => loader, + backgroundDecoration: const BoxDecoration( + color: Colors.transparent, + ), + initialScale: PhotoViewComputedScale.contained, + minScale: PhotoViewComputedScale.contained, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Icon( + Icons.broken_image_outlined, + color: Colors.white38, + size: 64, + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart b/lib/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart new file mode 100644 index 00000000..d69cef2c --- /dev/null +++ b/lib/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_icon_button.element.dart'; +import 'package:twonly/src/visual/elements/my_input.element.dart'; + +class MediaViewerMessageInput extends StatelessWidget { + const MediaViewerMessageInput({ + required this.controller, + required this.onSubmitted, + required this.onSendPressed, + super.key, + }); + + final TextEditingController controller; + final ValueChanged onSubmitted; + final VoidCallback onSendPressed; + + @override + Widget build(BuildContext context) { + return 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: [ + Expanded( + child: MyInput( + dense: true, + autofocus: true, + controller: controller, + hintText: context.lang.chatListDetailInput, + onChanged: (value) {}, + onSubmitted: onSubmitted, + ), + ), + const SizedBox(width: 10), + MyIconButton( + icon: const FaIcon( + FontAwesomeIcons.solidPaperPlane, + size: 20, + ), + onPressed: onSendPressed, + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart b/lib/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart index ac7f34e8..71d419fa 100644 --- a/lib/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart +++ b/lib/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart @@ -37,7 +37,6 @@ class ReactionButtons extends StatefulWidget { } class _ReactionButtonsState extends State { - int selectedShortReaction = -1; final GlobalKey _keyEmojiPicker = GlobalKey(); bool _renderAnimations = false; @@ -74,23 +73,40 @@ class _ReactionButtonsState extends State { ? selectedEmojis.skip(6).toList() : []; + final show = widget.show; + + final targetBottom = widget.textInputFocused + ? 50.0 + : widget.mediaViewerDistanceFromBottom; + + final bottomPosition = show + ? targetBottom + : widget.mediaViewerDistanceFromBottom - 20; + + final targetOpacity = show ? 1.0 : 0.0; + final isIgnoring = !show; + + final positionDuration = show + ? const Duration(milliseconds: 200) + : Duration.zero; + + final opacityDuration = show + ? const Duration(milliseconds: 150) + : Duration.zero; + return AnimatedPositioned( - duration: const Duration(milliseconds: 200), // Animation duration - bottom: widget.show - ? (widget.textInputFocused - ? 50 - : widget.mediaViewerDistanceFromBottom) - : widget.mediaViewerDistanceFromBottom - 20, + duration: positionDuration, // Animation duration + bottom: bottomPosition, left: 0, right: 0, curve: Curves.linearToEaseOut, child: IgnorePointer( - ignoring: !widget.show, + ignoring: isIgnoring, child: AnimatedOpacity( - opacity: widget.show ? 1.0 : 0.0, // Fade in/out - duration: Duration(milliseconds: widget.show ? 150 : 50), + opacity: targetOpacity, // Fade in/out + duration: opacityDuration, child: Container( - color: widget.show ? Colors.black.withAlpha(0) : Colors.transparent, + color: Colors.transparent, padding: const EdgeInsets.symmetric(vertical: 32), child: Column( children: [ diff --git a/lib/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart b/lib/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart new file mode 100644 index 00000000..7fb71011 --- /dev/null +++ b/lib/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; +import 'package:twonly/src/utils/misc.dart'; + +class TwonlyPresentOverlay extends StatelessWidget { + const TwonlyPresentOverlay({ + required this.onTap, + super.key, + }); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Positioned.fill( + child: GestureDetector( + onTap: onTap, + child: Column( + children: [ + Expanded( + child: Lottie.asset( + 'assets/animations/present.lottie.lottie', + ), + ), + Container( + padding: const EdgeInsets.only(bottom: 200), + child: Text( + context.lang.mediaViewerTwonlyTapToOpen, + ), + ), + ], + ), + ), + ); + } +}