split up the media viewer into statless widgets

This commit is contained in:
otsmr 2026-06-19 16:26:12 +02:00
parent 40b645e803
commit 0007ba62a0
5 changed files with 369 additions and 287 deletions

View file

@ -1,15 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:math';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:lottie/lottie.dart';
import 'package:mutex/mutex.dart'; import 'package:mutex/mutex.dart';
import 'package:photo_view/photo_view.dart';
import 'package:screen_protector/screen_protector.dart'; import 'package:screen_protector/screen_protector.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/locator.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/utils/misc.dart';
import 'package:twonly/src/visual/components/animate_icon.comp.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_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/helpers/media_view_sizing.helper.dart';
import 'package:twonly/src/visual/loader/three_rotating_dots.loader.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/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/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/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'; import 'package:video_player/video_player.dart';
class MediaViewerView extends StatefulWidget { class MediaViewerView extends StatefulWidget {
@ -64,6 +63,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
DateTime? canBeSeenUntil; DateTime? canBeSeenUntil;
final ValueNotifier<double> progress = ValueNotifier(0); final ValueNotifier<double> progress = ValueNotifier(0);
bool showSendTextMessageInput = false; bool showSendTextMessageInput = false;
double maxBottomInset = 0;
DateTime? _lastTimeInputClosed;
final GlobalKey mediaWidgetKey = GlobalKey(); final GlobalKey mediaWidgetKey = GlobalKey();
bool imageSaved = false; bool imageSaved = false;
@ -82,9 +83,6 @@ class _MediaViewerViewState extends State<MediaViewerView> {
final HashSet<String> _alreadyOpenedMediaIds = HashSet(); final HashSet<String> _alreadyOpenedMediaIds = HashSet();
bool _isTransitioning = false; bool _isTransitioning = false;
bool _isZoomed = false;
late PageController _verticalPager;
final ValueNotifier<double> _backdropOpacityNotifier = ValueNotifier(1);
@override @override
void initState() { void initState() {
@ -95,12 +93,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
allMediaFiles = [widget.initialMessage!]; allMediaFiles = [widget.initialMessage!];
} }
_verticalPager = PageController(initialPage: 1); listenForUnopenedMedia(true);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _verticalPager.addListener(_onVerticalScrollUpdated);
});
asyncLoadNextMedia(true);
} }
@override @override
@ -127,21 +120,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
); );
textMessageController.dispose(); textMessageController.dispose();
_verticalPager
..removeListener(_onVerticalScrollUpdated)
..dispose();
_backdropOpacityNotifier.dispose();
super.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() { void _disposeVideoController() {
final listener = _videoListener; final listener = _videoListener;
final controller = videoController; final controller = videoController;
@ -160,7 +141,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
(ModalRoute.of(context)?.isCurrent ?? false); (ModalRoute.of(context)?.isCurrent ?? false);
} }
Future<void> asyncLoadNextMedia(bool firstRun) async { Future<void> listenForUnopenedMedia(bool firstRun) async {
_subscription = twonlyDB.messagesDao _subscription = twonlyDB.messagesDao
.watchMediaNotOpened(widget.group.groupId) .watchMediaNotOpened(widget.group.groupId)
.listen((messages) async { .listen((messages) async {
@ -174,7 +155,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
} }
if (msg.mediaId == currentMedia?.mediaFile.mediaId) { 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; continue;
} }
@ -207,13 +188,13 @@ class _MediaViewerViewState extends State<MediaViewerView> {
if (mounted) setState(() {}); if (mounted) setState(() {});
if (firstRun) { if (firstRun) {
firstRun = false; firstRun = false;
await loadCurrentMediaFile(); await loadAndDownloadCurrentMedia();
} }
}); });
}); });
} }
Future<void> nextMediaOrExit() async { Future<void> advanceToNextMediaOrExit() async {
if (_isTransitioning) return; if (_isTransitioning) return;
_isTransitioning = true; _isTransitioning = true;
@ -245,17 +226,17 @@ class _MediaViewerViewState extends State<MediaViewerView> {
} }
} }
} else { } else {
await loadCurrentMediaFile(); await loadAndDownloadCurrentMedia();
} }
} finally { } finally {
if (mounted) _isTransitioning = false; if (mounted) _isTransitioning = false;
} }
} }
Future<void> loadCurrentMediaFile({bool showTwonly = false}) async { Future<void> loadAndDownloadCurrentMedia({bool showTwonly = false}) async {
if (!mounted || !context.mounted) return; if (!mounted || !context.mounted) return;
if (allMediaFiles.isEmpty || allMediaFiles.first.mediaId == null) { if (allMediaFiles.isEmpty || allMediaFiles.first.mediaId == null) {
return nextMediaOrExit(); return advanceToNextMediaOrExit();
} }
try { try {
@ -293,7 +274,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
// Media file record no longer exists skip to next or exit rather // Media file record no longer exists skip to next or exit rather
// than leaving the screen permanently black with no content/loader. // than leaving the screen permanently black with no content/loader.
await downloadStateListener?.cancel(); await downloadStateListener?.cancel();
await nextMediaOrExit(); await advanceToNextMediaOrExit();
return; return;
} }
if (updated.downloadState != DownloadState.ready) { if (updated.downloadState != DownloadState.ready) {
@ -308,7 +289,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
if (mediaFile == null) { if (mediaFile == null) {
// DB record gone skip to next or exit. // DB record gone skip to next or exit.
await downloadStateListener?.cancel(); await downloadStateListener?.cancel();
await nextMediaOrExit(); await advanceToNextMediaOrExit();
return; return;
} }
await startDownloadMedia(mediaFile, true); await startDownloadMedia(mediaFile, true);
@ -319,18 +300,16 @@ class _MediaViewerViewState extends State<MediaViewerView> {
await downloadStateListener?.cancel(); await downloadStateListener?.cancel();
try { try {
await handleNextDownloadedMedia(showTwonly); await initializeAndDisplayCurrentMedia(showTwonly);
} catch (e, st) { } catch (e, st) {
Log.error('handleNextDownloadedMedia failed: $e\n$st'); Log.error('initializeAndDisplayCurrentMedia failed: $e\n$st');
await nextMediaOrExit(); await advanceToNextMediaOrExit();
} }
// start downloading all the other possible missing media files. // start downloading all the other possible missing media files.
}); });
} }
Future<void> handleNextDownloadedMedia( Future<void> initializeAndDisplayCurrentMedia(bool showTwonly) async {
bool showTwonly,
) async {
if (allMediaFiles.isEmpty) return; if (allMediaFiles.isEmpty) return;
setState(() { setState(() {
_showDownloadingLoader = false; _showDownloadingLoader = false;
@ -355,7 +334,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
if (!mounted) return; if (!mounted) return;
if (!isAuth) { if (!isAuth) {
await nextMediaOrExit(); await advanceToNextMediaOrExit();
if (mounted) { if (mounted) {
setState(() { setState(() {
displayTwonlyPresent = false; displayTwonlyPresent = false;
@ -372,25 +351,55 @@ class _MediaViewerViewState extends State<MediaViewerView> {
displayTwonlyPresent = false; displayTwonlyPresent = false;
}); });
if (!widget.group.isDirectChat) { await _updateSenderInfo();
final sender = await twonlyDB.contactsDao.getContactById( if (!mounted) return;
currentMessage!.senderId!,
);
if (!mounted) return; await _notifyMessageOpened(currentMediaLocal);
if (!mounted) return;
if (sender != null) { if (!currentMediaLocal.tempPath.existsSync()) {
_currentMediaSender = Log.error('Temp media file not found...');
'${getContactDisplayName(sender)} (${widget.group.groupName})'; 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<void> _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<void> _notifyMessageOpened(MediaFileService mediaLocal) async {
if (currentMessage == null) return;
var markAsOpenMessageIDs = [currentMessage!.messageId]; var markAsOpenMessageIDs = [currentMessage!.messageId];
if (userService.currentUser.automaticallyMarkEqualMediaFilesAsOpened && if (userService.currentUser.automaticallyMarkEqualMediaFilesAsOpened &&
currentMediaLocal.mediaFile.storedFileHash != null) { mediaLocal.mediaFile.storedFileHash != null) {
final messageIds = await twonlyDB.mediaFilesDao.getMessageIdsByMediaHash( final messageIds = await twonlyDB.mediaFilesDao.getMessageIdsByMediaHash(
currentMediaLocal.mediaFile.storedFileHash!, mediaLocal.mediaFile.storedFileHash!,
currentMessage!.senderId!, currentMessage!.senderId!,
); );
@ -408,106 +417,82 @@ class _MediaViewerViewState extends State<MediaViewerView> {
currentMessage!.senderId!, currentMessage!.senderId!,
markAsOpenMessageIDs, markAsOpenMessageIDs,
); );
}
if (!mounted) return; Future<void> _setupVideoPlayer(MediaFileService mediaLocal) async {
final controller = VideoPlayerController.file(
if (!currentMediaLocal.tempPath.existsSync()) { mediaLocal.tempPath,
Log.error('Temp media file not found...'); videoPlayerOptions: VideoPlayerOptions(
await handleMediaError(currentMediaLocal.mediaFile); mixWithOthers: mediaLocal.mediaFile.displayLimitInMilliseconds == null,
return nextMediaOrExit(); ),
}
// The server can now delete the encrypted bytes, as the users has sucessfully opened it.
unawaited(
apiService.downloadDone(currentMediaLocal.mediaFile.downloadToken!),
); );
var timerRequired = false; await controller.setLooping(
mediaLocal.mediaFile.displayLimitInMilliseconds == null,
);
if (currentMediaLocal.mediaFile.type == MediaType.video) { if (!mounted) {
final controller = VideoPlayerController.file( await controller.dispose();
currentMediaLocal.tempPath, return;
videoPlayerOptions: VideoPlayerOptions( }
// only mix in case the video can be played multiple times,
// otherwise stop the background music in case the video contains audio await controller
mixWithOthers: .initialize()
currentMediaLocal.mediaFile.displayLimitInMilliseconds == null, .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!,
), ),
); );
if (mounted) {
await controller.setLooping( startProgressTimer();
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();
} }
} }
} }
void startTimer() { void startProgressTimer() {
nextMediaTimer?.cancel(); nextMediaTimer?.cancel();
progressTimer?.cancel(); progressTimer?.cancel();
if (canBeSeenUntil != null) { if (canBeSeenUntil != null) {
nextMediaTimer = Timer(canBeSeenUntil!.difference(clock.now()), () { nextMediaTimer = Timer(canBeSeenUntil!.difference(clock.now()), () {
if (context.mounted) { if (context.mounted) {
nextMediaOrExit(); advanceToNextMediaOrExit();
} }
}); });
progressTimer = Timer.periodic(const Duration(milliseconds: 10), (timer) { progressTimer = Timer.periodic(const Duration(milliseconds: 10), (timer) {
@ -653,7 +638,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
); );
if (mounted && if (mounted &&
currentMedia!.mediaFile.displayLimitInMilliseconds != null) { currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
await nextMediaOrExit(); await advanceToNextMediaOrExit();
} else { } else {
await videoController?.play(); await videoController?.play();
} }
@ -677,19 +662,43 @@ class _MediaViewerViewState extends State<MediaViewerView> {
); );
} }
void onTap() { void onScreenTapped() {
if (_lastTimeInputClosed != null &&
clock.now().difference(_lastTimeInputClosed!) <
const Duration(milliseconds: 300)) {
return;
}
if (showSendTextMessageInput) { if (showSendTextMessageInput) {
setState(() { setState(() {
showShortReactions = false; showShortReactions = false;
showSendTextMessageInput = false; showSendTextMessageInput = false;
_lastTimeInputClosed = clock.now();
}); });
return; return;
} }
nextMediaOrExit(); advanceToNextMediaOrExit();
} }
@override @override
Widget build(BuildContext context) { 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( return Scaffold(
body: SafeArea( body: SafeArea(
child: Stack( child: Stack(
@ -699,93 +708,21 @@ class _MediaViewerViewState extends State<MediaViewerView> {
if ((currentMedia != null || videoController != null) && if ((currentMedia != null || videoController != null) &&
(canBeSeenUntil == null || progress.value >= 0)) (canBeSeenUntil == null || progress.value >= 0))
GestureDetector( GestureDetector(
onTap: onTap, onTap: onScreenTapped,
onDoubleTap: (videoController == null) ? null : onTap, onDoubleTap: (videoController == null) ? null : onScreenTapped,
child: MediaViewSizingHelper( child: MediaViewSizingHelper(
bottomNavigation: bottomNavigation(), bottomNavigation: bottomNavigation(),
requiredHeight: 55, requiredHeight: 55,
child: Stack( child: MediaContentRenderer(
children: [ currentMedia: currentMedia,
if (videoController != null) videoController: videoController,
Positioned.fill( loader: _loader(),
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,
),
);
},
),
),
],
), ),
), ),
), ),
if (displayTwonlyPresent) if (displayTwonlyPresent)
Positioned.fill( TwonlyPresentOverlay(
child: GestureDetector( onTap: () => loadAndDownloadCurrentMedia(showTwonly: true),
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,
),
),
],
),
),
), ),
if (currentMedia != null && if (currentMedia != null &&
currentMedia?.mediaFile.downloadState != DownloadState.ready) currentMedia?.mediaFile.downloadState != DownloadState.ready)
@ -835,63 +772,32 @@ class _MediaViewerViewState extends State<MediaViewerView> {
), ),
), ),
if (showSendTextMessageInput) if (showSendTextMessageInput)
Positioned( MediaViewerMessageInput(
bottom: 0, controller: textMessageController,
left: 0, onSubmitted: (value) {
right: 0, setState(() {
child: Container( showSendTextMessageInput = false;
color: context.color.surface, showShortReactions = false;
padding: const EdgeInsets.only( _lastTimeInputClosed = clock.now();
bottom: 10, });
left: 20, },
right: 20, onSendPressed: () {
top: 10, if (textMessageController.text.isNotEmpty) {
), unawaited(
child: Row( insertAndSendTextMessage(
children: [ widget.group.groupId,
Expanded( textMessageController.text,
child: MyInput( currentMessage!.messageId,
dense: true,
autofocus: true,
controller: textMessageController,
hintText: context.lang.chatListDetailInput,
onChanged: (value) {
setState(() {});
},
onSubmitted: (value) {
setState(() {
showSendTextMessageInput = false;
showShortReactions = false;
});
},
),
), ),
const SizedBox(width: 10), );
MyIconButton( textMessageController.clear();
icon: const FaIcon( }
FontAwesomeIcons.solidPaperPlane, setState(() {
size: 20, showSendTextMessageInput = false;
), showShortReactions = false;
onPressed: () async { _lastTimeInputClosed = clock.now();
if (textMessageController.text.isNotEmpty) { });
unawaited( },
insertAndSendTextMessage(
widget.group.groupId,
textMessageController.text,
currentMessage!.messageId,
),
);
textMessageController.clear();
}
setState(() {
showSendTextMessageInput = false;
showShortReactions = false;
});
},
),
],
),
),
), ),
if (currentMessage != null) if (currentMessage != null)
AdditionalMessageContent(currentMessage!), AdditionalMessageContent(currentMessage!),
@ -907,6 +813,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
setState(() { setState(() {
showShortReactions = false; showShortReactions = false;
showSendTextMessageInput = false; showSendTextMessageInput = false;
_lastTimeInputClosed = clock.now();
}); });
}, },
), ),

View file

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

View file

@ -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<String> 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,
),
],
),
),
);
}
}

View file

@ -37,7 +37,6 @@ class ReactionButtons extends StatefulWidget {
} }
class _ReactionButtonsState extends State<ReactionButtons> { class _ReactionButtonsState extends State<ReactionButtons> {
int selectedShortReaction = -1;
final GlobalKey _keyEmojiPicker = GlobalKey(); final GlobalKey _keyEmojiPicker = GlobalKey();
bool _renderAnimations = false; bool _renderAnimations = false;
@ -74,23 +73,40 @@ class _ReactionButtonsState extends State<ReactionButtons> {
? selectedEmojis.skip(6).toList() ? 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( return AnimatedPositioned(
duration: const Duration(milliseconds: 200), // Animation duration duration: positionDuration, // Animation duration
bottom: widget.show bottom: bottomPosition,
? (widget.textInputFocused
? 50
: widget.mediaViewerDistanceFromBottom)
: widget.mediaViewerDistanceFromBottom - 20,
left: 0, left: 0,
right: 0, right: 0,
curve: Curves.linearToEaseOut, curve: Curves.linearToEaseOut,
child: IgnorePointer( child: IgnorePointer(
ignoring: !widget.show, ignoring: isIgnoring,
child: AnimatedOpacity( child: AnimatedOpacity(
opacity: widget.show ? 1.0 : 0.0, // Fade in/out opacity: targetOpacity, // Fade in/out
duration: Duration(milliseconds: widget.show ? 150 : 50), duration: opacityDuration,
child: Container( child: Container(
color: widget.show ? Colors.black.withAlpha(0) : Colors.transparent, color: Colors.transparent,
padding: const EdgeInsets.symmetric(vertical: 32), padding: const EdgeInsets.symmetric(vertical: 32),
child: Column( child: Column(
children: [ children: [

View file

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