diff --git a/lib/src/providers/api/api.dart b/lib/src/providers/api/api.dart index d80077b..9241627 100644 --- a/lib/src/providers/api/api.dart +++ b/lib/src/providers/api/api.dart @@ -161,7 +161,11 @@ Future encryptAndSendMessage( } Future sendTextMessage( - int target, TextMessageContent content, PushKind? pushKind) async { + int target, + TextMessageContent content, + PushKind? pushKind, { + int? responseToMessageId, +}) async { DateTime messageSendAt = DateTime.now(); int? messageId = await twonlyDatabase.messagesDao.insertMessage( @@ -170,6 +174,7 @@ Future sendTextMessage( kind: Value(MessageKind.textMessage), sendAt: Value(messageSendAt), responseToOtherMessageId: Value(content.responseToMessageId), + responseToMessageId: Value(responseToMessageId), downloadState: Value(DownloadState.downloaded), contentJson: Value( jsonEncode(content.toJson()), diff --git a/lib/src/utils/misc.dart b/lib/src/utils/misc.dart index e4442d0..7e952e9 100644 --- a/lib/src/utils/misc.dart +++ b/lib/src/utils/misc.dart @@ -214,3 +214,31 @@ bool isToday(DateTime lastImageSend) { lastImageSend.month == now.month && lastImageSend.day == now.day; } + +InputDecoration inputTextMessageDeco(BuildContext context) { + return InputDecoration( + hintText: context.lang.chatListDetailInput, + contentPadding: EdgeInsets.symmetric(horizontal: 20, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: + BorderSide(color: Theme.of(context).colorScheme.primary, width: 2.0), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20.0), + borderSide: + BorderSide(color: Theme.of(context).colorScheme.primary, width: 2.0), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20.0), + borderSide: BorderSide(color: Colors.grey, width: 2.0), + ), + ); +} + +String truncateString(String input, {int maxLength = 20}) { + if (input.length > maxLength) { + return '${input.substring(0, maxLength)}...'; + } + return input; +} diff --git a/lib/src/views/chats/chat_item_details_view.dart b/lib/src/views/chats/chat_item_details_view.dart index cd8da92..c947814 100644 --- a/lib/src/views/chats/chat_item_details_view.dart +++ b/lib/src/views/chats/chat_item_details_view.dart @@ -2,495 +2,26 @@ import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'dart:io'; -import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/globals.dart'; -import 'package:twonly/src/providers/api/media_send.dart' as send; -import 'package:twonly/src/views/components/animate_icon.dart'; -import 'package:twonly/src/views/components/better_text.dart'; +import 'package:twonly/src/views/chats/components/chat_list_entry.dart'; import 'package:twonly/src/views/components/initialsavatar.dart'; -import 'package:twonly/src/views/components/message_send_state_icon.dart'; import 'package:twonly/src/views/components/verified_shield.dart'; import 'package:twonly/src/database/daos/contacts_dao.dart'; import 'package:twonly/src/database/twonly_database.dart'; import 'package:twonly/src/database/tables/messages_table.dart'; import 'package:twonly/src/model/json/message.dart'; import 'package:twonly/src/providers/api/api.dart'; -import 'package:twonly/src/providers/api/media_received.dart' as received; import 'package:twonly/src/services/notification_service.dart'; import 'package:twonly/src/views/camera/camera_send_to_view.dart'; -import 'package:twonly/src/views/chats/media_viewer_view.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/contact/contact_view.dart'; -import 'package:video_player/video_player.dart'; -class ChatMediaViewerFullScreen extends StatelessWidget { - const ChatMediaViewerFullScreen({super.key, required this.message}); - final Message message; - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Center( - child: InChatMediaViewer(message: message, isInFullscreen: true), - ), - ), - ); - } -} - -class InChatMediaViewer extends StatefulWidget { - const InChatMediaViewer( - {super.key, required this.message, this.isInFullscreen = false}); - - final Message message; - final bool isInFullscreen; - - @override - State createState() => _InChatMediaViewerState(); -} - -class _InChatMediaViewerState extends State { - File? image; - File? video; - bool isMounted = true; - bool mirrorVideo = false; - VideoPlayerController? videoController; - - @override - void initState() { - super.initState(); - initAsync(); - } - - Future initAsync() async { - if (!widget.message.mediaStored) return; - bool isSend = widget.message.messageOtherId == null; - final basePath = await send.getMediaFilePath( - isSend ? widget.message.mediaUploadId! : widget.message.messageId, - isSend ? "send" : "received", - ); - if (!isMounted) return; - final videoPath = File("$basePath.mp4"); - final imagePath = File("$basePath.png"); - if (videoPath.existsSync() && widget.message.contentJson != null) { - MessageContent? content = MessageContent.fromJson( - MessageKind.media, jsonDecode(widget.message.contentJson!)); - if (content is MediaMessageContent) { - mirrorVideo = content.mirrorVideo; - } - videoController = VideoPlayerController.file(videoPath); - videoController?.initialize().then((_) { - if (!widget.isInFullscreen) { - videoController!.setVolume(0); - } - videoController!.play(); - }); - - setState(() { - image = imagePath; - }); - } - if (imagePath.existsSync()) { - setState(() { - image = imagePath; - }); - } else { - print("Not found: $imagePath"); - } - } - - @override - void dispose() { - super.dispose(); - isMounted = false; - videoController?.dispose(); - } - - Future deleteFiles() async { - await twonlyDatabase.messagesDao.updateMessageByMessageId( - widget.message.messageId, - MessagesCompanion(mediaStored: Value(false)), - ); - await send.purgeSendMediaFiles(); - await received.purgeReceivedMediaFiles(); - if (context.mounted) { - Navigator.pop(context, true); - } - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: (image == null && videoController == null) - ? null - : () async { - if (widget.isInFullscreen) return; - bool? removed = await Navigator.push( - context, - MaterialPageRoute(builder: (context) { - return ChatMediaViewerFullScreen(message: widget.message); - }), - ); - - if (removed != null && removed) { - image = null; - videoController?.dispose(); - videoController = null; - setState(() {}); - } - }, - child: Stack( - children: [ - if (image != null) Image.file(image!), - if (videoController != null) - Positioned.fill( - child: Transform.flip( - flipX: mirrorVideo, - child: VideoPlayer(videoController!), - ), - ), - if (image == null && video == null) - Padding( - padding: const EdgeInsets.all(10.0), - child: MessageSendStateIcon( - [widget.message], - mainAxisAlignment: MainAxisAlignment.center, - ), - ), - if (widget.isInFullscreen) - Positioned( - bottom: 10, - left: 0, - right: 0, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - OutlinedButton.icon( - onPressed: deleteFiles, - icon: FaIcon(FontAwesomeIcons.trashCan), - label: Text("Delete media file"), - ) - ], - ), - ), - ], - ), - ); - } -} - -class ChatListEntry extends StatelessWidget { - const ChatListEntry( - this.message, this.contact, this.lastMessageFromSameUser, this.reactions, - {super.key}); - final Message message; - final Contact contact; - final bool lastMessageFromSameUser; - final List reactions; - - Widget getReactionRow() { - List children = []; - bool hasOneTextReaction = false; - // bool hasOneStored = false; - bool hasOneReopened = false; - for (final reaction in reactions) { - MessageContent? content = MessageContent.fromJson( - reaction.kind, jsonDecode(reaction.contentJson!)); - - // if (content is StoredMediaFileContent || message.mediaStored) { - // if (hasOneStored) continue; - // hasOneStored = true; - // children.add( - // Expanded( - // child: Align( - // alignment: Alignment.bottomRight, - // child: Padding( - // padding: EdgeInsets.only(right: 3), - // child: FaIcon( - // FontAwesomeIcons.floppyDisk, - // size: 12, - // color: Colors.blue, - // ), - // ), - // ), - // ), - // ); - // } - - if (content is ReopenedMediaFileContent) { - if (hasOneReopened) continue; - hasOneReopened = true; - children.add( - Expanded( - child: Align( - alignment: Alignment.bottomRight, - child: Padding( - padding: EdgeInsets.only(right: 3), - child: FaIcon( - FontAwesomeIcons.repeat, - size: 12, - color: Colors.white, - ), - ), - ), - ), - ); - } - // only show one reaction - if (hasOneTextReaction) continue; - - if (content is TextMessageContent) { - hasOneTextReaction = true; - if (!isEmoji(content.text)) continue; - late Widget child; - if (EmojiAnimation.animatedIcons.containsKey(content.text)) { - child = SizedBox( - height: 18, - child: EmojiAnimation(emoji: content.text), - ); - } else { - child = Text(content.text, style: TextStyle(fontSize: 14)); - } - children.insert( - 0, - Padding( - padding: EdgeInsets.only(left: 3), - child: child, - ), - ); - } - } - - if (children.isEmpty) return Container(); - - return Row( - mainAxisAlignment: message.messageOtherId == null - ? MainAxisAlignment.start - : MainAxisAlignment.end, - children: children, - ); - } - - Widget getTextResponseColumns(BuildContext context, bool right) { - List children = []; - for (final reaction in reactions) { - MessageContent? content = MessageContent.fromJson( - reaction.kind, jsonDecode(reaction.contentJson!)); - - if (content is TextMessageContent) { - if (content.text.length <= 1) continue; - if (isEmoji(content.text)) continue; - var entries = [ - FaIcon( - FontAwesomeIcons.reply, - size: 10, - ), - SizedBox(width: 5), - Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.5, - ), - child: Text( - content.text, - style: TextStyle(fontSize: 14), - textAlign: right ? TextAlign.left : TextAlign.right, - )), - ]; - if (!right) { - entries = entries.reversed.toList(); - } - - children.insert( - 0, - Container( - padding: EdgeInsets.only(top: 5, bottom: 0, right: 10, left: 10), - child: Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.8, - ), - padding: EdgeInsets.symmetric(vertical: 1, horizontal: 10), - decoration: BoxDecoration( - color: right - ? const Color.fromARGB(107, 124, 77, 255) - : const Color.fromARGB(83, 68, 137, 255), - borderRadius: BorderRadius.circular(12.0), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: entries, - ), - ), - ), - ); - } - } - - if (children.isEmpty) return Container(); - - return Column( - // mainAxisAlignment: message.messageOtherId == null - // ? MainAxisAlignment.start - // : MainAxisAlignment.end, - crossAxisAlignment: - right ? CrossAxisAlignment.start : CrossAxisAlignment.end, - children: children, - ); - } - - @override - Widget build(BuildContext context) { - bool right = message.messageOtherId == null; - - MessageContent? content = - MessageContent.fromJson(message.kind, jsonDecode(message.contentJson!)); - - Widget child = Container(); - - if (content is TextMessageContent) { - if (EmojiAnimation.supported(content.text)) { - child = child = Container( - constraints: BoxConstraints( - maxWidth: 100, - ), - padding: EdgeInsets.symmetric( - vertical: 4, horizontal: 10), // Add some padding around the text - child: EmojiAnimation(emoji: content.text), - ); - } else { - child = Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.8, - ), - padding: EdgeInsets.symmetric(vertical: 4, horizontal: 10), - decoration: BoxDecoration( - color: right - ? const Color.fromARGB(107, 124, 77, 255) - : const Color.fromARGB(83, 68, 137, 255), - borderRadius: BorderRadius.circular(12.0), - ), - child: BetterText(text: content.text), - ); - } - } else if (content is MediaMessageContent) { - Color color = getMessageColorFromType( - content, - context, - ); - - child = GestureDetector( - onDoubleTap: () async { - if (message.openedAt == null && message.messageOtherId != null || - message.mediaStored) { - return; - } - if (await received.existsMediaFile(message.messageId, "png")) { - encryptAndSendMessage( - null, - contact.userId, - MessageJson( - kind: MessageKind.reopenedMedia, - messageId: message.messageId, - content: ReopenedMediaFileContent( - messageId: message.messageOtherId!, - ), - timestamp: DateTime.now(), - ), - pushKind: PushKind.reopenedMedia, - ); - await twonlyDatabase.messagesDao.updateMessageByMessageId( - message.messageId, - MessagesCompanion(openedAt: Value(null)), - ); - } - }, - onTap: () { - if (message.kind == MessageKind.media) { - if (message.downloadState == DownloadState.downloaded && - message.openedAt == null) { - Navigator.push( - context, - MaterialPageRoute(builder: (context) { - return MediaViewerView(contact); - }), - ); - } else if (message.downloadState == DownloadState.pending) { - received.startDownloadMedia(message, true); - } - } - }, - child: Container( - width: 150, - decoration: BoxDecoration( - border: Border.all( - color: color, - width: 1.0, - ), - borderRadius: BorderRadius.circular(12.0), - ), - child: Align( - alignment: Alignment.centerRight, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: InChatMediaViewer(message: message), - ), - ), - ), - ); - } else if (message.kind == MessageKind.storedMediaFile) { - child = Container( - padding: EdgeInsets.all(5), - width: 150, - decoration: BoxDecoration( - border: Border.all( - color: - getMessageColorFromType(TextMessageContent(text: ""), context), - width: 1.0, - ), - borderRadius: BorderRadius.circular(12.0), - ), - child: Align( - alignment: Alignment.centerRight, - child: MessageSendStateIcon( - [message], - mainAxisAlignment: - right ? MainAxisAlignment.center : MainAxisAlignment.center, - ), - ), - ); - } - - return Align( - alignment: right ? Alignment.centerRight : Alignment.centerLeft, - child: Padding( - padding: lastMessageFromSameUser - ? EdgeInsets.only(top: 5, bottom: 0, right: 10, left: 10) - : EdgeInsets.only(top: 5, bottom: 20, right: 10, left: 10), - child: Column( - mainAxisAlignment: - right ? MainAxisAlignment.end : MainAxisAlignment.start, - crossAxisAlignment: - right ? CrossAxisAlignment.end : CrossAxisAlignment.start, - children: [ - Stack( - alignment: right ? Alignment.centerRight : Alignment.centerLeft, - children: [ - child, - Positioned( - bottom: 5, - left: 5, - right: 5, - child: getReactionRow(), - ), - ], - ), - getTextResponseColumns(context, !right) - ], - ), - ), - ); - } +Color getMessageColor(Message message) { + return (message.messageOtherId == null) + ? Color.fromARGB(107, 124, 77, 255) + : Color.fromARGB(83, 68, 137, 255); } /// Displays detailed information about a SampleItem. @@ -513,11 +44,14 @@ class _ChatItemDetailsViewState extends State { List messages = []; Map> reactionsToMyMessages = {}; Map> reactionsToOtherMessages = {}; + Message? responseToMessage; + late FocusNode textFieldFocus; @override void initState() { super.initState(); user = widget.contact; + textFieldFocus = FocusNode(); initStreams(); } @@ -526,6 +60,7 @@ class _ChatItemDetailsViewState extends State { super.dispose(); userSub.cancel(); messageSub.cancel(); + textFieldFocus.dispose(); } Future initStreams() async { @@ -550,7 +85,7 @@ class _ChatItemDetailsViewState extends State { List displayedMessages = []; // should be cleared Map> tmpReactionsToMyMessages = {}; - Map> tmpTeactionsToOtherMessages = {}; + Map> tmpReactionsToOtherMessages = {}; List openedMessageOtherIds = []; for (Message msg in msgs) { @@ -560,20 +95,20 @@ class _ChatItemDetailsViewState extends State { openedMessageOtherIds.add(msg.messageOtherId!); } - if (msg.responseToMessageId != null) { + if (msg.responseToOtherMessageId != null) { + if (!tmpReactionsToOtherMessages + .containsKey(msg.responseToOtherMessageId!)) { + tmpReactionsToOtherMessages[msg.responseToOtherMessageId!] = [msg]; + } else { + tmpReactionsToOtherMessages[msg.responseToOtherMessageId!]! + .add(msg); + } + } else if (msg.responseToMessageId != null) { if (!tmpReactionsToMyMessages.containsKey(msg.responseToMessageId!)) { tmpReactionsToMyMessages[msg.responseToMessageId!] = [msg]; } else { tmpReactionsToMyMessages[msg.responseToMessageId!]!.add(msg); } - } else if (msg.responseToOtherMessageId != null) { - if (!tmpTeactionsToOtherMessages - .containsKey(msg.responseToOtherMessageId!)) { - tmpTeactionsToOtherMessages[msg.responseToOtherMessageId!] = [msg]; - } else { - tmpTeactionsToOtherMessages[msg.responseToOtherMessageId!]! - .add(msg); - } } else { displayedMessages.add(msg); } @@ -589,7 +124,7 @@ class _ChatItemDetailsViewState extends State { // // The stream should be get an update, so only update the UI when all are opened setState(() { reactionsToMyMessages = tmpReactionsToMyMessages; - reactionsToOtherMessages = tmpTeactionsToOtherMessages; + reactionsToOtherMessages = tmpReactionsToOtherMessages; messages = displayedMessages; }); // } @@ -602,14 +137,68 @@ class _ChatItemDetailsViewState extends State { user.userId, TextMessageContent( text: newMessageController.text, + responseToMessageId: responseToMessage?.messageOtherId, ), PushKind.text, + responseToMessageId: responseToMessage?.messageId, ); newMessageController.clear(); currentInputText = ""; + responseToMessage = null; setState(() {}); } + Widget getResponsePreview(Message message) { + String? subtitle; + + if (message.kind == MessageKind.textMessage) { + if (message.contentJson != null) { + MessageContent? content = MessageContent.fromJson( + MessageKind.textMessage, jsonDecode(message.contentJson!)); + if (content is TextMessageContent) { + subtitle = truncateString(content.text); + } + } + } + if (message.kind == MessageKind.media) { + MessageContent? content = MessageContent.fromJson( + MessageKind.media, jsonDecode(message.contentJson!)); + if (content is MediaMessageContent) { + subtitle = content.isVideo ? "Video" : "Image"; + } + } + + String username = "You"; + if (message.messageOtherId != null) { + username = getContactDisplayName(widget.contact); + } + + Color color = getMessageColor(message); + + return Container( + padding: EdgeInsets.only(left: 10), + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: color, + width: 2.0, + ), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + username, + style: TextStyle(fontWeight: FontWeight.bold), + ), + if (subtitle != null) Text(subtitle) + ], + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -675,18 +264,54 @@ class _ChatItemDetailsViewState extends State { user, lastMessageFromSameUser, reactions, + onResponseTriggered: (message) { + setState(() { + responseToMessage = message; + }); + textFieldFocus.requestFocus(); + }, ); }, ), ), + if (responseToMessage != null) + Container( + padding: const EdgeInsets.only( + bottom: 00, + left: 20, + right: 20, + top: 10, + ), + child: Row( + children: [ + Expanded(child: getResponsePreview(responseToMessage!)), + IconButton( + onPressed: () { + setState(() { + responseToMessage = null; + }); + }, + icon: FaIcon( + FontAwesomeIcons.xmark, + size: 16, + ), + ) + ], + ), + ), Padding( padding: const EdgeInsets.only( - bottom: 30, left: 20, right: 20, top: 10), + bottom: 30, + left: 20, + right: 20, + top: 10, + ), child: Row( children: [ Expanded( child: TextField( controller: newMessageController, + focusNode: textFieldFocus, onChanged: (value) { currentInputText = value; setState(() {}); @@ -708,11 +333,14 @@ class _ChatItemDetailsViewState extends State { : IconButton( icon: FaIcon(FontAwesomeIcons.camera), onPressed: () { - Navigator.push(context, MaterialPageRoute( - builder: (context) { - return CameraSendToView(widget.contact); - }, - )); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return CameraSendToView(widget.contact); + }, + ), + ); }, ) ], @@ -724,24 +352,3 @@ class _ChatItemDetailsViewState extends State { ); } } - -InputDecoration inputTextMessageDeco(BuildContext context) { - return InputDecoration( - hintText: context.lang.chatListDetailInput, - contentPadding: EdgeInsets.symmetric(horizontal: 20, vertical: 10), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - borderSide: - BorderSide(color: Theme.of(context).colorScheme.primary, width: 2.0), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(20.0), - borderSide: - BorderSide(color: Theme.of(context).colorScheme.primary, width: 2.0), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(20.0), - borderSide: BorderSide(color: Colors.grey, width: 2.0), - ), - ); -} diff --git a/lib/src/views/chats/components/chat_list_entry.dart b/lib/src/views/chats/components/chat_list_entry.dart new file mode 100644 index 0000000..5d98342 --- /dev/null +++ b/lib/src/views/chats/components/chat_list_entry.dart @@ -0,0 +1,317 @@ +import 'dart:convert'; +import 'package:drift/drift.dart' show Value; +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/globals.dart'; +import 'package:twonly/src/views/chats/chat_item_details_view.dart'; +import 'package:twonly/src/views/chats/components/in_chat_media_viewer.dart'; +import 'package:twonly/src/views/components/animate_icon.dart'; +import 'package:twonly/src/views/components/better_text.dart'; +import 'package:twonly/src/views/components/message_send_state_icon.dart'; +import 'package:twonly/src/views/chats/components/sliding_response.dart'; +import 'package:twonly/src/database/twonly_database.dart'; +import 'package:twonly/src/database/tables/messages_table.dart'; +import 'package:twonly/src/model/json/message.dart'; +import 'package:twonly/src/providers/api/api.dart'; +import 'package:twonly/src/providers/api/media_received.dart' as received; +import 'package:twonly/src/services/notification_service.dart'; +import 'package:twonly/src/views/chats/media_viewer_view.dart'; + +class ChatListEntry extends StatelessWidget { + const ChatListEntry( + this.message, + this.contact, + this.lastMessageFromSameUser, + this.reactions, { + super.key, + required this.onResponseTriggered, + }); + final Message message; + final Contact contact; + final bool lastMessageFromSameUser; + final List reactions; + final Function(Message) onResponseTriggered; + + Widget getReactionRow() { + List children = []; + bool hasOneTextReaction = false; + bool hasOneReopened = false; + for (final reaction in reactions) { + MessageContent? content = MessageContent.fromJson( + reaction.kind, jsonDecode(reaction.contentJson!)); + + if (content is ReopenedMediaFileContent) { + if (hasOneReopened) continue; + hasOneReopened = true; + children.add( + Expanded( + child: Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: EdgeInsets.only(right: 3), + child: FaIcon( + FontAwesomeIcons.repeat, + size: 12, + color: Colors.white, + ), + ), + ), + ), + ); + } + // only show one reaction + if (hasOneTextReaction) continue; + + if (content is TextMessageContent) { + hasOneTextReaction = true; + if (!isEmoji(content.text)) continue; + late Widget child; + if (EmojiAnimation.animatedIcons.containsKey(content.text)) { + child = SizedBox( + height: 18, + child: EmojiAnimation(emoji: content.text), + ); + } else { + child = Text(content.text, style: TextStyle(fontSize: 14)); + } + children.insert( + 0, + Padding( + padding: EdgeInsets.only(left: 3), + child: child, + ), + ); + } + } + + if (children.isEmpty) return Container(); + + return Row( + mainAxisAlignment: message.messageOtherId == null + ? MainAxisAlignment.start + : MainAxisAlignment.end, + children: children, + ); + } + + Widget getTextResponseColumns(BuildContext context, bool right) { + List children = []; + for (final reaction in reactions) { + MessageContent? content = MessageContent.fromJson( + reaction.kind, jsonDecode(reaction.contentJson!)); + + if (content is TextMessageContent) { + if (content.text.length <= 1) continue; + if (isEmoji(content.text)) continue; + var entries = [ + FaIcon( + FontAwesomeIcons.reply, + size: 10, + ), + SizedBox(width: 5), + Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.5, + ), + child: Text( + content.text, + style: TextStyle(fontSize: 14), + textAlign: right ? TextAlign.left : TextAlign.right, + )), + ]; + if (!right) { + entries = entries.reversed.toList(); + } + + Color color = getMessageColor(reaction); + + children.insert( + 0, + Container( + padding: EdgeInsets.only(top: 5, bottom: 0, right: 10, left: 10), + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.8, + ), + padding: EdgeInsets.symmetric(vertical: 1, horizontal: 10), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(12.0), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: entries, + ), + ), + ), + ); + } + } + + if (children.isEmpty) return Container(); + + return Column( + crossAxisAlignment: + right ? CrossAxisAlignment.start : CrossAxisAlignment.end, + children: children, + ); + } + + @override + Widget build(BuildContext context) { + bool right = message.messageOtherId == null; + + MessageContent? content = + MessageContent.fromJson(message.kind, jsonDecode(message.contentJson!)); + + Widget child = Container(); + + if (content is TextMessageContent) { + if (EmojiAnimation.supported(content.text)) { + child = child = Container( + constraints: BoxConstraints( + maxWidth: 100, + ), + padding: EdgeInsets.symmetric( + vertical: 4, + horizontal: 10, + ), + child: EmojiAnimation(emoji: content.text), + ); + } else { + child = Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.8, + ), + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 10), + decoration: BoxDecoration( + color: getMessageColor(message), + borderRadius: BorderRadius.circular(12.0), + ), + child: BetterText(text: content.text), + ); + } + } else if (content is MediaMessageContent) { + Color color = getMessageColorFromType( + content, + context, + ); + + child = GestureDetector( + onDoubleTap: () async { + if (message.openedAt == null && message.messageOtherId != null || + message.mediaStored) { + return; + } + if (await received.existsMediaFile(message.messageId, "png")) { + encryptAndSendMessage( + null, + contact.userId, + MessageJson( + kind: MessageKind.reopenedMedia, + messageId: message.messageId, + content: ReopenedMediaFileContent( + messageId: message.messageOtherId!, + ), + timestamp: DateTime.now(), + ), + pushKind: PushKind.reopenedMedia, + ); + await twonlyDatabase.messagesDao.updateMessageByMessageId( + message.messageId, + MessagesCompanion(openedAt: Value(null)), + ); + } + }, + onTap: () { + if (message.kind == MessageKind.media) { + if (message.downloadState == DownloadState.downloaded && + message.openedAt == null) { + Navigator.push( + context, + MaterialPageRoute(builder: (context) { + return MediaViewerView(contact); + }), + ); + } else if (message.downloadState == DownloadState.pending) { + received.startDownloadMedia(message, true); + } + } + }, + child: Container( + width: 150, + decoration: BoxDecoration( + border: Border.all( + color: color, + width: 1.0, + ), + borderRadius: BorderRadius.circular(12.0), + ), + child: Align( + alignment: Alignment.centerRight, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: InChatMediaViewer(message: message), + ), + ), + ), + ); + } else if (message.kind == MessageKind.storedMediaFile) { + child = Container( + padding: EdgeInsets.all(5), + width: 150, + decoration: BoxDecoration( + border: Border.all( + color: + getMessageColorFromType(TextMessageContent(text: ""), context), + width: 1.0, + ), + borderRadius: BorderRadius.circular(12.0), + ), + child: Align( + alignment: Alignment.centerRight, + child: MessageSendStateIcon( + [message], + mainAxisAlignment: + right ? MainAxisAlignment.center : MainAxisAlignment.center, + ), + ), + ); + } + + return Align( + alignment: right ? Alignment.centerRight : Alignment.centerLeft, + child: Padding( + padding: lastMessageFromSameUser + ? EdgeInsets.only(top: 5, bottom: 0, right: 10, left: 10) + : EdgeInsets.only(top: 5, bottom: 20, right: 10, left: 10), + child: Column( + mainAxisAlignment: + right ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: + right ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Stack( + alignment: right ? Alignment.centerRight : Alignment.centerLeft, + children: [ + SlidingResponse( + child: child, + onResponseTriggered: () { + onResponseTriggered(message); + }, + ), + Positioned( + bottom: 5, + left: 5, + right: 5, + child: getReactionRow(), + ), + ], + ), + getTextResponseColumns(context, !right) + ], + ), + ), + ); + } +} diff --git a/lib/src/views/chats/components/in_chat_media_viewer.dart b/lib/src/views/chats/components/in_chat_media_viewer.dart new file mode 100644 index 0000000..c12317f --- /dev/null +++ b/lib/src/views/chats/components/in_chat_media_viewer.dart @@ -0,0 +1,170 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:drift/drift.dart' show Value; +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/globals.dart'; +import 'package:twonly/src/providers/api/media_send.dart' as send; +import 'package:twonly/src/views/components/message_send_state_icon.dart'; +import 'package:twonly/src/database/twonly_database.dart'; +import 'package:twonly/src/database/tables/messages_table.dart'; +import 'package:twonly/src/model/json/message.dart'; +import 'package:twonly/src/providers/api/media_received.dart' as received; +import 'package:video_player/video_player.dart'; + +class ChatMediaViewerFullScreen extends StatelessWidget { + const ChatMediaViewerFullScreen({super.key, required this.message}); + final Message message; + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Center( + child: InChatMediaViewer(message: message, isInFullscreen: true), + ), + ), + ); + } +} + +class InChatMediaViewer extends StatefulWidget { + const InChatMediaViewer( + {super.key, required this.message, this.isInFullscreen = false}); + + final Message message; + final bool isInFullscreen; + + @override + State createState() => _InChatMediaViewerState(); +} + +class _InChatMediaViewerState extends State { + File? image; + File? video; + bool isMounted = true; + bool mirrorVideo = false; + VideoPlayerController? videoController; + + @override + void initState() { + super.initState(); + initAsync(); + } + + Future initAsync() async { + if (!widget.message.mediaStored) return; + bool isSend = widget.message.messageOtherId == null; + final basePath = await send.getMediaFilePath( + isSend ? widget.message.mediaUploadId! : widget.message.messageId, + isSend ? "send" : "received", + ); + if (!isMounted) return; + final videoPath = File("$basePath.mp4"); + final imagePath = File("$basePath.png"); + if (videoPath.existsSync() && widget.message.contentJson != null) { + MessageContent? content = MessageContent.fromJson( + MessageKind.media, jsonDecode(widget.message.contentJson!)); + if (content is MediaMessageContent) { + mirrorVideo = content.mirrorVideo; + } + videoController = VideoPlayerController.file(videoPath); + videoController?.initialize().then((_) { + if (!widget.isInFullscreen) { + videoController!.setVolume(0); + } + videoController!.play(); + }); + + setState(() { + image = imagePath; + }); + } + if (imagePath.existsSync()) { + setState(() { + image = imagePath; + }); + } else { + print("Not found: $imagePath"); + } + } + + @override + void dispose() { + super.dispose(); + isMounted = false; + videoController?.dispose(); + } + + Future deleteFiles() async { + await twonlyDatabase.messagesDao.updateMessageByMessageId( + widget.message.messageId, + MessagesCompanion(mediaStored: Value(false)), + ); + await send.purgeSendMediaFiles(); + await received.purgeReceivedMediaFiles(); + if (context.mounted) { + Navigator.pop(context, true); + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: (image == null && videoController == null) + ? null + : () async { + if (widget.isInFullscreen) return; + bool? removed = await Navigator.push( + context, + MaterialPageRoute(builder: (context) { + return ChatMediaViewerFullScreen(message: widget.message); + }), + ); + + if (removed != null && removed) { + image = null; + videoController?.dispose(); + videoController = null; + setState(() {}); + } + }, + child: Stack( + children: [ + if (image != null) Image.file(image!), + if (videoController != null) + Positioned.fill( + child: Transform.flip( + flipX: mirrorVideo, + child: VideoPlayer(videoController!), + ), + ), + if (image == null && video == null) + Padding( + padding: const EdgeInsets.all(10.0), + child: MessageSendStateIcon( + [widget.message], + mainAxisAlignment: MainAxisAlignment.center, + ), + ), + if (widget.isInFullscreen) + Positioned( + bottom: 10, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton.icon( + onPressed: deleteFiles, + icon: FaIcon(FontAwesomeIcons.trashCan), + label: Text("Delete media file"), + ) + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/views/chats/components/sliding_response.dart b/lib/src/views/chats/components/sliding_response.dart new file mode 100644 index 0000000..864cbbb --- /dev/null +++ b/lib/src/views/chats/components/sliding_response.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +class SlidingResponse extends StatefulWidget { + final Widget child; + final VoidCallback onResponseTriggered; + + const SlidingResponse({ + super.key, + required this.child, + required this.onResponseTriggered, + }); + + @override + State createState() => _SlidingResponseWidgetState(); +} + +class _SlidingResponseWidgetState extends State { + double _offset = 0.0; + + void _onHorizontalDragUpdate(DragUpdateDetails details) { + setState(() { + _offset += details.delta.dx; + if (_offset > 50) { + _offset = 50; + } + if (_offset < 0) _offset = 0; + }); + } + + void _onHorizontalDragEnd(DragEndDetails details) { + if (_offset >= 50) { + widget.onResponseTriggered(); + } + setState(() { + _offset = 0.0; + }); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Transform.translate( + offset: Offset(_offset, 0), + child: GestureDetector( + onHorizontalDragUpdate: _onHorizontalDragUpdate, + onHorizontalDragEnd: _onHorizontalDragEnd, + child: widget.child, + ), + ), + if (_offset >= 50) + Positioned( + left: 20, + top: 0, + bottom: 0, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FaIcon( + FontAwesomeIcons.reply, + size: 14, + // color: Colors.green, + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/views/chats/media_viewer_view.dart b/lib/src/views/chats/media_viewer_view.dart index 9b142b6..97a10d9 100644 --- a/lib/src/views/chats/media_viewer_view.dart +++ b/lib/src/views/chats/media_viewer_view.dart @@ -20,7 +20,6 @@ import 'package:twonly/src/services/notification_service.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/chats/chat_item_details_view.dart'; import 'package:video_player/video_player.dart'; final _noScreenshot = NoScreenshot.instance;