This commit is contained in:
otsmr 2025-07-18 01:28:56 +02:00
parent 63fa506fde
commit 0845701e16
15 changed files with 679 additions and 505 deletions

View file

@ -52,7 +52,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDatabase>
t.mediaStored.equals(true) | t.mediaStored.equals(true) |
t.openedAt.isBiggerThanValue( t.openedAt.isBiggerThanValue(
DateTime.now().subtract(const Duration(days: 1))))) DateTime.now().subtract(const Duration(days: 1)))))
..orderBy([(t) => OrderingTerm.desc(t.sendAt)])) ..orderBy([(t) => OrderingTerm.asc(t.sendAt)]))
.watch(); .watch();
} }

View file

@ -34,7 +34,7 @@ class MemoryItem {
final isSend = message.messageOtherId == null; final isSend = message.messageOtherId == null;
final id = message.mediaUploadId ?? message.messageId; final id = message.mediaUploadId ?? message.messageId;
final basePath = await send.getMediaFilePath( final basePath = await send.getMediaFilePath(
isSend ? message.mediaUploadId! : message.messageId, id,
isSend ? 'send' : 'received', isSend ? 'send' : 'received',
); );
File? imagePath; File? imagePath;

View file

@ -17,7 +17,9 @@ import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera/camera_send_to_view.dart'; import 'package:twonly/src/views/camera/camera_send_to_view.dart';
import 'package:twonly/src/views/chats/chat_messages_components/chat_message_entry.dart'; import 'package:twonly/src/views/chats/chat_messages_components/chat_date_chip.dart';
import 'package:twonly/src/views/chats/chat_messages_components/chat_list_entry.dart';
import 'package:twonly/src/views/chats/chat_messages_components/response_container.dart';
import 'package:twonly/src/views/components/animate_icon.dart'; import 'package:twonly/src/views/components/animate_icon.dart';
import 'package:twonly/src/views/components/initialsavatar.dart'; import 'package:twonly/src/views/components/initialsavatar.dart';
import 'package:twonly/src/views/components/user_context_menu.dart'; import 'package:twonly/src/views/components/user_context_menu.dart';
@ -31,6 +33,31 @@ Color getMessageColor(Message message) {
: const Color.fromARGB(233, 68, 137, 255); : const Color.fromARGB(233, 68, 137, 255);
} }
class ChatMessage {
ChatMessage({required this.message, required this.responseTo});
final Message message;
final Message? responseTo;
}
class ChatItem {
const ChatItem._({this.message, this.date, this.time});
factory ChatItem.date(DateTime date) {
return ChatItem._(date: date);
}
factory ChatItem.time(DateTime time) {
return ChatItem._(time: time);
}
factory ChatItem.message(ChatMessage message) {
return ChatItem._(message: message);
}
final ChatMessage? message;
final DateTime? date;
final DateTime? time;
bool get isMessage => message != null;
bool get isDate => date != null;
bool get isTime => time != null;
}
/// Displays detailed information about a SampleItem. /// Displays detailed information about a SampleItem.
class ChatMessagesView extends StatefulWidget { class ChatMessagesView extends StatefulWidget {
const ChatMessagesView(this.contact, {super.key}); const ChatMessagesView(this.contact, {super.key});
@ -48,9 +75,8 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
String currentInputText = ''; String currentInputText = '';
late StreamSubscription<Contact?> userSub; late StreamSubscription<Contact?> userSub;
late StreamSubscription<List<Message>> messageSub; late StreamSubscription<List<Message>> messageSub;
List<Message> messages = []; List<ChatItem> messages = [];
List<MemoryItem> galleryItems = []; List<MemoryItem> galleryItems = [];
Map<int, List<Message>> textReactionsToMessageId = {};
Map<int, List<Message>> emojiReactionsToMessageId = {}; Map<int, List<Message>> emojiReactionsToMessageId = {};
Message? responseToMessage; Message? responseToMessage;
GlobalKey verifyShieldKey = GlobalKey(); GlobalKey verifyShieldKey = GlobalKey();
@ -92,61 +118,85 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
final msgStream = final msgStream =
twonlyDB.messagesDao.watchAllMessagesFrom(widget.contact.userId); twonlyDB.messagesDao.watchAllMessagesFrom(widget.contact.userId);
messageSub = msgStream.listen((msgs) async { messageSub = msgStream.listen((newMessages) async {
// if (!context.mounted) return; // if (!context.mounted) return;
if (Platform.isAndroid) { if (Platform.isAndroid) {
await flutterLocalNotificationsPlugin.cancel(widget.contact.userId); await flutterLocalNotificationsPlugin.cancel(widget.contact.userId);
} else { } else {
await flutterLocalNotificationsPlugin.cancelAll(); await flutterLocalNotificationsPlugin.cancelAll();
} }
final displayedMessages = <Message>[]; final chatItems = <ChatItem>[];
// should be cleared final storedMediaFiles = <Message>[];
final tmpTextReactionsToMessageId = <int, List<Message>>{}; DateTime? lastDate;
final tmpEmojiReactionsToMessageId = <int, List<Message>>{}; final tmpEmojiReactionsToMessageId = <int, List<Message>>{};
final openedMessageOtherIds = <int>[]; final openedMessageOtherIds = <int>[];
final messageOtherMessageIdToMyMessageId = <int, int>{}; final messageOtherMessageIdToMyMessageId = <int, int>{};
final messageIdToMessage = <int, Message>{};
/// there is probably a better way... /// there is probably a better way...
for (final msg in msgs) { for (final msg in newMessages) {
if (msg.messageOtherId != null) { if (msg.messageOtherId != null) {
messageOtherMessageIdToMyMessageId[msg.messageOtherId!] = messageOtherMessageIdToMyMessageId[msg.messageOtherId!] =
msg.messageId; msg.messageId;
} }
messageIdToMessage[msg.messageId] = msg;
} }
for (final msg in msgs) { for (final msg in newMessages) {
if (msg.kind == MessageKind.textMessage && if (msg.kind == MessageKind.textMessage &&
msg.messageOtherId != null && msg.messageOtherId != null &&
msg.openedAt == null) { msg.openedAt == null) {
openedMessageOtherIds.add(msg.messageOtherId!); openedMessageOtherIds.add(msg.messageOtherId!);
} }
Message? responseTo;
if (msg.kind == MessageKind.media && msg.mediaStored) {
storedMediaFiles.add(msg);
}
final responseId = msg.responseToMessageId ?? final responseId = msg.responseToMessageId ??
messageOtherMessageIdToMyMessageId[msg.responseToOtherMessageId]; messageOtherMessageIdToMyMessageId[msg.responseToOtherMessageId];
var isReaction = false;
if (responseId != null) { if (responseId != null) {
var added = false; responseTo = messageIdToMessage[responseId];
final content = MessageContent.fromJson( final content = MessageContent.fromJson(
msg.kind, msg.kind,
jsonDecode(msg.contentJson!) as Map, jsonDecode(msg.contentJson!) as Map,
); );
if (content is TextMessageContent) { if (content is TextMessageContent) {
if (content.text.isNotEmpty && !isEmoji(content.text)) { if (isEmoji(content.text)) {
added = true; isReaction = true;
tmpTextReactionsToMessageId tmpEmojiReactionsToMessageId
.putIfAbsent(responseId, () => []) .putIfAbsent(responseId, () => [])
.add(msg); .add(msg);
} }
} }
if (!added) { if (msg.kind == MessageKind.reopenedMedia) {
isReaction = true;
tmpEmojiReactionsToMessageId tmpEmojiReactionsToMessageId
.putIfAbsent(responseId, () => []) .putIfAbsent(responseId, () => [])
.add(msg); .add(msg);
} }
} else { }
displayedMessages.add(msg); if (!isReaction) {
if (lastDate == null ||
msg.sendAt.day != lastDate.day ||
msg.sendAt.month != lastDate.month ||
msg.sendAt.year != lastDate.year) {
chatItems.add(ChatItem.date(msg.sendAt));
lastDate = msg.sendAt;
} else if (msg.sendAt.difference(lastDate).inMinutes >= 20) {
chatItems.add(ChatItem.time(msg.sendAt));
lastDate = msg.sendAt;
}
chatItems.add(ChatItem.message(ChatMessage(
message: msg,
responseTo: responseTo,
)));
} }
} }
@ -161,17 +211,11 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
.openedAllNonMediaMessages(widget.contact.userId); .openedAllNonMediaMessages(widget.contact.userId);
setState(() { setState(() {
textReactionsToMessageId = tmpTextReactionsToMessageId;
emojiReactionsToMessageId = tmpEmojiReactionsToMessageId; emojiReactionsToMessageId = tmpEmojiReactionsToMessageId;
messages = displayedMessages; messages = chatItems.reversed.toList();
}); });
final filteredMediaFiles = displayedMessages final items = await MemoryItem.convertFromMessages(storedMediaFiles);
.where((x) => x.kind == MessageKind.media && x.mediaStored)
.toList()
.reversed
.toList();
final items = await MemoryItem.convertFromMessages(filteredMediaFiles);
galleryItems = items.values.toList(); galleryItems = items.values.toList();
setState(() {}); setState(() {});
}); });
@ -204,56 +248,6 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
setState(() {}); setState(() {});
} }
Widget getResponsePreview(Message message) {
String? subtitle;
if (message.kind == MessageKind.textMessage) {
if (message.contentJson != null) {
final content = MessageContent.fromJson(
MessageKind.textMessage, jsonDecode(message.contentJson!) as Map);
if (content is TextMessageContent) {
subtitle = truncateString(content.text);
}
}
}
if (message.kind == MessageKind.media) {
final content = MessageContent.fromJson(
MessageKind.media, jsonDecode(message.contentJson!) as Map);
if (content is MediaMessageContent) {
subtitle = content.isVideo ? 'Video' : 'Image';
}
}
var username = 'You';
if (message.messageOtherId != null) {
username = getContactDisplayName(widget.contact);
}
final color = getMessageColor(message);
return Container(
padding: const EdgeInsets.only(left: 10),
decoration: BoxDecoration(
border: Border(
left: BorderSide(
color: color,
width: 2,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
username,
style: const TextStyle(fontWeight: FontWeight.bold),
),
if (subtitle != null) Text(subtitle)
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
@ -296,70 +290,37 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
children: [ children: [
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
itemCount: messages.length + 1,
reverse: true, reverse: true,
itemExtentBuilder: (index, dimensions) { itemCount: messages.length + 1,
if (index == 0) return 10; // empty padding
index -= 1;
double size = 44;
if (messages[index].kind == MessageKind.textMessage) {
final content = TextMessageContent.fromJson(
jsonDecode(messages[index].contentJson!) as Map);
if (EmojiAnimation.supported(content.text)) {
size = 99;
} else {
size = 11 +
calculateNumberOfLines(
content.text,
MediaQuery.of(context).size.width * 0.8,
17) *
27;
}
}
if (messages[index].mediaStored) {
size = 271;
}
final reactions =
textReactionsToMessageId[messages[index].messageId];
if (reactions != null && reactions.isNotEmpty) {
for (final reaction in reactions) {
if (reaction.kind == MessageKind.textMessage) {
final content = TextMessageContent.fromJson(
jsonDecode(reaction.contentJson!) as Map);
size += calculateNumberOfLines(
content.text,
MediaQuery.of(context).size.width * 0.5,
14) *
27;
}
}
}
if (!isLastMessageFromSameUser(messages, index)) {
size += 20;
}
return size;
},
itemBuilder: (context, i) { itemBuilder: (context, i) {
if (i == 0) { if (i == messages.length) {
return Container(); // just a padding return const Padding(
padding: EdgeInsetsGeometry.only(top: 10),
);
}
if (messages[i].isDate || messages[i].isTime) {
return ChatDateChip(
item: messages[i],
);
} else {
final chatMessage = messages[i].message!;
return ChatListEntry(
key: Key(chatMessage.message.messageId.toString()),
chatMessage,
user,
galleryItems,
isLastMessageFromSameUser(messages, i),
emojiReactionsToMessageId[
chatMessage.message.messageId] ??
[],
onResponseTriggered: () {
setState(() {
responseToMessage = chatMessage.message;
});
textFieldFocus.requestFocus();
},
);
} }
i -= 1;
return ChatListEntry(
key: Key(messages[i].messageId.toString()),
messages[i],
user,
galleryItems,
isLastMessageFromSameUser(messages, i),
textReactionsToMessageId[messages[i].messageId] ?? [],
emojiReactionsToMessageId[messages[i].messageId] ?? [],
onResponseTriggered: (message) {
setState(() {
responseToMessage = message;
});
textFieldFocus.requestFocus();
},
);
}, },
), ),
), ),
@ -372,7 +333,13 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
), ),
child: Row( child: Row(
children: [ children: [
Expanded(child: getResponsePreview(responseToMessage!)), Expanded(
child: ResponsePreview(
message: responseToMessage!,
showBorder: true,
contact: user,
),
),
IconButton( IconButton(
onPressed: () { onPressed: () {
setState(() { setState(() {
@ -449,7 +416,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
} }
} }
bool isLastMessageFromSameUser(List<Message> messages, int index) { bool isLastMessageFromSameUser(List<ChatItem> messages, int index) {
if (index <= 0) { if (index <= 0) {
return true; // If there is no previous message, return true return true; // If there is no previous message, return true
} }
@ -457,11 +424,14 @@ bool isLastMessageFromSameUser(List<Message> messages, int index) {
final lastMessage = messages[index - 1]; final lastMessage = messages[index - 1];
final currentMessage = messages[index]; final currentMessage = messages[index];
// Check if both messages have the same messageOtherId (or both are null) if (lastMessage.isMessage && currentMessage.isMessage) {
return (lastMessage.messageOtherId == null && // Check if both messages have the same messageOtherId (or both are null)
currentMessage.messageOtherId == null) || return (lastMessage.message!.message.messageOtherId == null &&
(lastMessage.messageOtherId != null && currentMessage.message!.message.messageOtherId == null) ||
currentMessage.messageOtherId != null); (lastMessage.message!.message.messageOtherId != null &&
currentMessage.message!.message.messageOtherId != null);
}
return false;
} }
double calculateNumberOfLines(String text, double width, double fontSize) { double calculateNumberOfLines(String text, double width, double fontSize) {

View file

@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/chats/chat_messages.view.dart';
class ChatDateChip extends StatelessWidget {
const ChatDateChip({required this.item, super.key});
final ChatItem item;
@override
Widget build(BuildContext context) {
var formattedDate = item.isTime
? DateFormat.Hm(Localizations.localeOf(context).toLanguageTag())
.format(item.time!)
: '${DateFormat.Hm(Localizations.localeOf(context).toLanguageTag()).format(item.date!)} ${DateFormat.yMd(Localizations.localeOf(context).toLanguageTag()).format(item.date!)}';
return Center(
child: Container(
decoration: BoxDecoration(
color: Colors.black.withAlpha(40),
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Text(
formattedDate,
style: TextStyle(
fontSize: 10,
color: isDarkMode(context) ? Colors.white : Colors.black,
),
),
),
);
}
}

View file

@ -0,0 +1,112 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/model/json/message.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/views/chats/chat_messages.view.dart';
import 'package:twonly/src/views/chats/chat_messages_components/chat_media_entry.dart';
import 'package:twonly/src/views/chats/chat_messages_components/chat_reaction_row.dart';
import 'package:twonly/src/views/chats/chat_messages_components/chat_text_entry.dart';
import 'package:twonly/src/views/chats/chat_messages_components/message_actions.dart';
import 'package:twonly/src/views/chats/chat_messages_components/message_context_menu.dart';
import 'package:twonly/src/views/chats/chat_messages_components/response_container.dart';
class ChatListEntry extends StatefulWidget {
const ChatListEntry(
this.msg,
this.contact,
this.galleryItems,
this.lastMessageFromSameUser,
this.otherReactions, {
required this.onResponseTriggered,
super.key,
});
final ChatMessage msg;
final Contact contact;
final bool lastMessageFromSameUser;
final List<Message> otherReactions;
final List<MemoryItem> galleryItems;
final void Function() onResponseTriggered;
@override
State<ChatListEntry> createState() => _ChatListEntryState();
}
class _ChatListEntryState extends State<ChatListEntry> {
MessageContent? content;
String? textMessage;
@override
void initState() {
super.initState();
final msgContent = MessageContent.fromJson(widget.msg.message.kind,
jsonDecode(widget.msg.message.contentJson!) as Map);
if (msgContent is TextMessageContent) {
textMessage = msgContent.text;
}
content = msgContent;
}
@override
Widget build(BuildContext context) {
if (content == null) return Container();
final right = widget.msg.message.messageOtherId == null;
return Align(
alignment: right ? Alignment.centerRight : Alignment.centerLeft,
child: Padding(
padding: widget.lastMessageFromSameUser
? const EdgeInsets.only(top: 5, right: 10, left: 10)
: const EdgeInsets.only(top: 5, bottom: 20, right: 10, left: 10),
child: MessageContextMenu(
message: widget.msg.message,
onResponseTriggered: widget.onResponseTriggered,
child: Column(
mainAxisAlignment:
right ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment:
right ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
MessageActions(
message: widget.msg.message,
onResponseTriggered: widget.onResponseTriggered,
child: Stack(
alignment:
right ? Alignment.centerRight : Alignment.centerLeft,
children: [
ResponseContainer(
msg: widget.msg,
contact: widget.contact,
child: (textMessage != null)
? ChatTextEntry(
message: widget.msg.message,
text: textMessage!,
hasReaction: widget.otherReactions.isNotEmpty,
)
: ChatMediaEntry(
message: widget.msg.message,
contact: widget.contact,
galleryItems: widget.galleryItems,
content: content!,
),
),
Positioned(
bottom: 5,
left: 5,
right: 5,
child: ReactionRow(
otherReactions: widget.otherReactions,
message: widget.msg.message,
),
),
],
),
),
],
),
),
),
);
}
}

View file

@ -59,6 +59,51 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
} }
} }
Future<void> onDoubleTap() async {
if (widget.message.openedAt == null &&
widget.message.messageOtherId != null ||
widget.message.mediaStored) {
return;
}
if (await received.existsMediaFile(widget.message.messageId, 'png')) {
await encryptAndSendMessageAsync(
null,
widget.contact.userId,
MessageJson(
kind: MessageKind.reopenedMedia,
messageSenderId: widget.message.messageId,
content: ReopenedMediaFileContent(
messageId: widget.message.messageOtherId!,
),
timestamp: DateTime.now(),
),
pushNotification: PushNotification(
kind: PushKind.reopenedMedia,
),
);
await twonlyDB.messagesDao.updateMessageByMessageId(
widget.message.messageId,
const MessagesCompanion(openedAt: Value(null)),
);
}
}
Future<void> onTap() async {
if (widget.message.downloadState == DownloadState.downloaded &&
widget.message.openedAt == null) {
await Navigator.push(
context,
MaterialPageRoute(builder: (context) {
return MediaViewerView(widget.contact,
initialMessage: widget.message);
}),
);
await checkIfTutorialCanBeShown();
} else if (widget.message.downloadState == DownloadState.pending) {
await received.startDownloadMedia(widget.message, true);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = getMessageColorFromType( final color = getMessageColorFromType(
@ -68,53 +113,11 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
return GestureDetector( return GestureDetector(
key: reopenMediaFile, key: reopenMediaFile,
onDoubleTap: () async { onDoubleTap: onDoubleTap,
if (widget.message.openedAt == null && onTap: widget.message.kind == MessageKind.media ? onTap : null,
widget.message.messageOtherId != null ||
widget.message.mediaStored) {
return;
}
if (await received.existsMediaFile(widget.message.messageId, 'png')) {
await encryptAndSendMessageAsync(
null,
widget.contact.userId,
MessageJson(
kind: MessageKind.reopenedMedia,
messageSenderId: widget.message.messageId,
content: ReopenedMediaFileContent(
messageId: widget.message.messageOtherId!,
),
timestamp: DateTime.now(),
),
pushNotification: PushNotification(
kind: PushKind.reopenedMedia,
),
);
await twonlyDB.messagesDao.updateMessageByMessageId(
widget.message.messageId,
const MessagesCompanion(openedAt: Value(null)),
);
}
},
onTap: () async {
if (widget.message.kind == MessageKind.media) {
if (widget.message.downloadState == DownloadState.downloaded &&
widget.message.openedAt == null) {
await Navigator.push(
context,
MaterialPageRoute(builder: (context) {
return MediaViewerView(widget.contact,
initialMessage: widget.message);
}),
);
await checkIfTutorialCanBeShown();
} else if (widget.message.downloadState == DownloadState.pending) {
await received.startDownloadMedia(widget.message, true);
}
}
},
child: SizedBox( child: SizedBox(
width: 150, width: 150,
height: widget.message.mediaStored ? 271 : null,
child: Align( child: Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: ClipRRect( child: ClipRRect(

View file

@ -1,109 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/model/json/message.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/views/chats/chat_messages_components/chat_media_entry.dart';
import 'package:twonly/src/views/chats/chat_messages_components/chat_reaction_row.dart';
import 'package:twonly/src/views/chats/chat_messages_components/chat_text_entry.dart';
import 'package:twonly/src/views/chats/chat_messages_components/chat_text_response_columns.dart';
import 'package:twonly/src/views/chats/chat_messages_components/message_actions.dart';
class ChatListEntry extends StatefulWidget {
const ChatListEntry(
this.message,
this.contact,
this.galleryItems,
this.lastMessageFromSameUser,
this.textReactions,
this.otherReactions, {
required this.onResponseTriggered,
super.key,
});
final Message message;
final Contact contact;
final bool lastMessageFromSameUser;
final List<Message> textReactions;
final List<Message> otherReactions;
final List<MemoryItem> galleryItems;
final void Function(Message) onResponseTriggered;
@override
State<ChatListEntry> createState() => _ChatListEntryState();
}
class _ChatListEntryState extends State<ChatListEntry> {
MessageContent? content;
String? textMessage;
@override
void initState() {
super.initState();
final msgContent = MessageContent.fromJson(
widget.message.kind, jsonDecode(widget.message.contentJson!) as Map);
if (msgContent is TextMessageContent) {
textMessage = msgContent.text;
}
content = msgContent;
}
@override
Widget build(BuildContext context) {
if (content == null) return Container();
final right = widget.message.messageOtherId == null;
return Align(
alignment: right ? Alignment.centerRight : Alignment.centerLeft,
child: Padding(
padding: widget.lastMessageFromSameUser
? const EdgeInsets.only(top: 5, right: 10, left: 10)
: const 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: [
MessageActions(
message: widget.message,
child: Stack(
alignment: right ? Alignment.centerRight : Alignment.centerLeft,
children: [
if (textMessage != null)
ChatTextEntry(
message: widget.message,
text: textMessage!,
)
else
ChatMediaEntry(
message: widget.message,
contact: widget.contact,
galleryItems: widget.galleryItems,
content: content!,
),
Positioned(
bottom: 5,
left: 5,
right: 5,
child: ReactionRow(
otherReactions: widget.otherReactions,
message: widget.message,
),
),
],
),
onResponseTriggered: () {
widget.onResponseTriggered(widget.message);
},
),
ChatTextResponseColumns(
textReactions: widget.textReactions,
right: right,
)
],
),
),
);
}
}

View file

@ -4,6 +4,7 @@ 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:twonly/src/database/twonly_database.dart'; import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/model/json/message.dart'; import 'package:twonly/src/model/json/message.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/components/animate_icon.dart'; import 'package:twonly/src/views/components/animate_icon.dart';
class ReactionRow extends StatefulWidget { class ReactionRow extends StatefulWidget {
@ -26,7 +27,7 @@ class _ReactionRowState extends State<ReactionRow> {
final children = <Widget>[]; final children = <Widget>[];
var hasOneTextReaction = false; var hasOneTextReaction = false;
var hasOneReopened = false; var hasOneReopened = false;
for (final reaction in widget.otherReactions) { for (final reaction in widget.otherReactions.reversed) {
final content = MessageContent.fromJson( final content = MessageContent.fromJson(
reaction.kind, jsonDecode(reaction.contentJson!) as Map); reaction.kind, jsonDecode(reaction.contentJson!) as Map);
@ -34,15 +35,15 @@ class _ReactionRowState extends State<ReactionRow> {
if (hasOneReopened) continue; if (hasOneReopened) continue;
hasOneReopened = true; hasOneReopened = true;
children.add( children.add(
const Expanded( Expanded(
child: Align( child: Align(
alignment: Alignment.bottomRight, alignment: Alignment.bottomRight,
child: Padding( child: Padding(
padding: EdgeInsets.only(right: 3), padding: const EdgeInsets.only(right: 3),
child: FaIcon( child: FaIcon(
FontAwesomeIcons.repeat, FontAwesomeIcons.repeat,
size: 12, size: 12,
color: Colors.white, color: isDarkMode(context) ? Colors.white : Colors.black,
), ),
), ),
), ),
@ -78,7 +79,7 @@ class _ReactionRowState extends State<ReactionRow> {
return Row( return Row(
mainAxisAlignment: widget.message.messageOtherId == null mainAxisAlignment: widget.message.messageOtherId == null
? MainAxisAlignment.start ? MainAxisAlignment.end
: MainAxisAlignment.end, : MainAxisAlignment.end,
children: children, children: children,
); );

View file

@ -5,10 +5,16 @@ import 'package:twonly/src/views/components/animate_icon.dart';
import 'package:twonly/src/views/components/better_text.dart'; import 'package:twonly/src/views/components/better_text.dart';
class ChatTextEntry extends StatelessWidget { class ChatTextEntry extends StatelessWidget {
const ChatTextEntry({required this.message, required this.text, super.key}); const ChatTextEntry({
required this.message,
required this.text,
required this.hasReaction,
super.key,
});
final String text; final String text;
final Message message; final Message message;
final bool hasReaction;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -28,9 +34,12 @@ class ChatTextEntry extends StatelessWidget {
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8, maxWidth: MediaQuery.of(context).size.width * 0.8,
), ),
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 10), padding: EdgeInsets.only(
left: 10, top: 4, bottom: 4, right: hasReaction ? 30 : 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: getMessageColor(message), color: message.responseToMessageId == null
? getMessageColor(message)
: null,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: BetterText(text: text), child: BetterText(text: text),

View file

@ -1,80 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/model/json/message.dart';
import 'package:twonly/src/views/chats/chat_messages.view.dart';
class ChatTextResponseColumns extends StatelessWidget {
const ChatTextResponseColumns({
required this.textReactions,
required this.right,
super.key,
});
final List<Message> textReactions;
final bool right;
@override
Widget build(BuildContext context) {
final children = <Widget>[];
for (final reaction in textReactions) {
final content = MessageContent.fromJson(
reaction.kind, jsonDecode(reaction.contentJson!) as Map);
if (content is TextMessageContent) {
var entries = [
const FaIcon(
FontAwesomeIcons.reply,
size: 10,
),
const SizedBox(width: 5),
Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.5,
),
child: Text(
content.text,
style: const TextStyle(fontSize: 14),
textAlign: right ? TextAlign.right : TextAlign.left,
)),
];
if (right) {
entries = entries.reversed.toList();
}
final color = getMessageColor(reaction);
children.insert(
0,
Container(
padding: const EdgeInsets.only(top: 5, right: 10, left: 10),
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
),
padding: const EdgeInsets.symmetric(vertical: 1, horizontal: 10),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: entries,
),
),
),
);
}
}
if (children.isEmpty) return Container();
return Column(
crossAxisAlignment:
right ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: children,
);
}
}

View file

@ -108,7 +108,7 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (galleryItemIndex == null) { if (!widget.message.mediaStored) {
return Container( return Container(
constraints: const BoxConstraints( constraints: const BoxConstraints(
minHeight: 39, minHeight: 39,
@ -138,10 +138,12 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
color: Colors.transparent, color: Colors.transparent,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: MemoriesItemThumbnail( child: galleryItemIndex != null
galleryItem: widget.galleryItems[galleryItemIndex!], ? MemoriesItemThumbnail(
onTap: onTap, galleryItem: widget.galleryItems[galleryItemIndex!],
), onTap: onTap,
)
: null,
); );
} }
} }

View file

@ -3,18 +3,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:pie_menu/pie_menu.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/tables/messages_table.dart';
import 'package:twonly/src/database/twonly_database.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.pbserver.dart';
import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera/image_editor/data/layer.dart';
import 'package:twonly/src/views/camera/image_editor/modules/all_emojis.dart';
import 'package:twonly/src/views/components/alert_dialog.dart';
class MessageActions extends StatefulWidget { class MessageActions extends StatefulWidget {
const MessageActions({ const MessageActions({
@ -70,11 +59,7 @@ class _SlidingResponseWidgetState extends State<MessageActions> {
child: GestureDetector( child: GestureDetector(
onHorizontalDragUpdate: _onHorizontalDragUpdate, onHorizontalDragUpdate: _onHorizontalDragUpdate,
onHorizontalDragEnd: _onHorizontalDragEnd, onHorizontalDragEnd: _onHorizontalDragEnd,
child: MessageContextMenu( child: widget.child,
message: widget.message,
onResponseTriggered: widget.onResponseTriggered,
child: widget.child,
),
), ),
), ),
if (_offsetX >= 40) if (_offsetX >= 40)
@ -97,100 +82,3 @@ class _SlidingResponseWidgetState extends State<MessageActions> {
); );
} }
} }
class MessageContextMenu extends StatelessWidget {
const MessageContextMenu({
required this.message,
required this.child,
required this.onResponseTriggered,
super.key,
});
final Widget child;
final Message message;
final VoidCallback onResponseTriggered;
@override
Widget build(BuildContext context) {
return PieMenu(
onPressed: () => (),
onToggle: (menuOpen) {
if (menuOpen) {
HapticFeedback.heavyImpact();
}
},
actions: [
PieAction(
tooltip: Text(context.lang.react),
onSelect: () async {
final layer = await showModalBottomSheet(
context: context,
backgroundColor: Colors.black,
builder: (BuildContext context) {
return const Emojis();
},
) as TextLayerData?;
if (layer == null) return;
Log.info(layer.text);
await sendTextMessage(
message.contactId,
TextMessageContent(
text: layer.text,
responseToMessageId: message.messageOtherId,
responseToOtherMessageId: (message.messageOtherId == null)
? message.messageId
: null),
(message.messageOtherId != null)
? PushNotification(
kind: (message.kind == MessageKind.textMessage)
? PushKind.reactionToText
: (getMediaContent(message)!.isVideo)
? PushKind.reactionToVideo
: PushKind.reactionToImage,
reactionContent: layer.text,
)
: null,
);
},
child: const FaIcon(FontAwesomeIcons.faceLaugh),
),
PieAction(
tooltip: Text(context.lang.reply),
onSelect: onResponseTriggered,
child: const FaIcon(FontAwesomeIcons.reply),
),
PieAction(
tooltip: Text(context.lang.copy),
onSelect: () {
final text = getMessageText(message);
Clipboard.setData(ClipboardData(text: text));
HapticFeedback.heavyImpact();
},
child: const FaIcon(FontAwesomeIcons.solidCopy),
),
PieAction(
tooltip: Text(context.lang.delete),
onSelect: () async {
final delete = await showAlertDialog(
context,
context.lang.deleteTitle,
null,
customOk: context.lang.deleteOkBtn,
);
if (delete) {
await twonlyDB.messagesDao
.deleteMessagesByMessageId(message.messageId);
}
},
child: const FaIcon(FontAwesomeIcons.trash),
),
// PieAction(
// tooltip: Text(context.lang.info),
// onSelect: () {},
// child: const FaIcon(FontAwesomeIcons.circleInfo),
// ),
],
child: child,
);
}
}

View file

@ -0,0 +1,114 @@
// ignore_for_file: avoid_dynamic_calls, inference_failure_on_function_invocation
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:pie_menu/pie_menu.dart';
import 'package:twonly/globals.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.pbserver.dart';
import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera/image_editor/data/layer.dart';
import 'package:twonly/src/views/camera/image_editor/modules/all_emojis.dart';
import 'package:twonly/src/views/components/alert_dialog.dart';
class MessageContextMenu extends StatelessWidget {
const MessageContextMenu({
required this.message,
required this.child,
required this.onResponseTriggered,
super.key,
});
final Widget child;
final Message message;
final VoidCallback onResponseTriggered;
@override
Widget build(BuildContext context) {
return PieMenu(
onPressed: () => (),
onToggle: (menuOpen) {
if (menuOpen) {
HapticFeedback.heavyImpact();
}
},
actions: [
PieAction(
tooltip: Text(context.lang.react),
onSelect: () async {
final layer = await showModalBottomSheet(
context: context,
backgroundColor: Colors.black,
builder: (BuildContext context) {
return const Emojis();
},
) as EmojiLayerData?;
if (layer == null) return;
Log.info(layer.text);
await sendTextMessage(
message.contactId,
TextMessageContent(
text: layer.text,
responseToMessageId: message.messageOtherId,
responseToOtherMessageId: (message.messageOtherId == null)
? message.messageId
: null),
(message.messageOtherId != null)
? PushNotification(
kind: (message.kind == MessageKind.textMessage)
? PushKind.reactionToText
: (getMediaContent(message)!.isVideo)
? PushKind.reactionToVideo
: PushKind.reactionToImage,
reactionContent: layer.text,
)
: null,
);
},
child: const FaIcon(FontAwesomeIcons.faceLaugh),
),
PieAction(
tooltip: Text(context.lang.reply),
onSelect: onResponseTriggered,
child: const FaIcon(FontAwesomeIcons.reply),
),
PieAction(
tooltip: Text(context.lang.copy),
onSelect: () {
final text = getMessageText(message);
Clipboard.setData(ClipboardData(text: text));
HapticFeedback.heavyImpact();
},
child: const FaIcon(FontAwesomeIcons.solidCopy),
),
PieAction(
tooltip: Text(context.lang.delete),
onSelect: () async {
final delete = await showAlertDialog(
context,
context.lang.deleteTitle,
null,
customOk: context.lang.deleteOkBtn,
);
if (delete) {
await twonlyDB.messagesDao
.deleteMessagesByMessageId(message.messageId);
}
},
child: const FaIcon(FontAwesomeIcons.trash),
),
// PieAction(
// tooltip: Text(context.lang.info),
// onSelect: () {},
// child: const FaIcon(FontAwesomeIcons.circleInfo),
// ),
],
child: child,
);
}
}

View file

@ -0,0 +1,227 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.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/memory_item.model.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/chats/chat_messages.view.dart';
class ResponseContainer extends StatefulWidget {
const ResponseContainer({
required this.msg,
required this.contact,
required this.child,
super.key,
});
final ChatMessage msg;
final Widget child;
final Contact contact;
@override
State<ResponseContainer> createState() => _ResponseContainerState();
}
class _ResponseContainerState extends State<ResponseContainer> {
double? minWidth;
final GlobalKey _message = GlobalKey();
final GlobalKey _preview = GlobalKey();
@override
void didChangeDependencies() {
super.didChangeDependencies();
WidgetsBinding.instance.addPostFrameCallback((_) {
final messageBox =
_message.currentContext?.findRenderObject() as RenderBox?;
final previewBox =
_preview.currentContext?.findRenderObject() as RenderBox?;
if (messageBox == null || previewBox == null) {
return;
}
setState(() {
if (messageBox.size.width > previewBox.size.width) {
minWidth = messageBox.size.width;
} else {
minWidth = previewBox.size.width;
}
});
});
}
@override
Widget build(BuildContext context) {
if (widget.msg.responseTo == null) {
return widget.child;
}
return Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
),
decoration: BoxDecoration(
color: getMessageColor(widget.msg.message),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 4, right: 4, left: 4),
child: Container(
key: _preview,
width: minWidth,
decoration: BoxDecoration(
color: context.color.surface.withAlpha(150),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(8),
topLeft: Radius.circular(8),
bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(4),
),
),
child: ResponsePreview(
contact: widget.contact,
message: widget.msg.responseTo!,
showBorder: false,
),
),
),
SizedBox(
key: _message,
width: minWidth,
child: widget.child,
),
],
),
);
}
}
class ResponsePreview extends StatefulWidget {
const ResponsePreview({
required this.message,
required this.contact,
required this.showBorder,
super.key,
});
final Message message;
final Contact contact;
final bool showBorder;
@override
State<ResponsePreview> createState() => _ResponsePreviewState();
}
class _ResponsePreviewState extends State<ResponsePreview> {
File? thumbnailPath;
@override
void initState() {
initAsync();
super.initState();
}
Future<void> initAsync() async {
final items = await MemoryItem.convertFromMessages([widget.message]);
if (items.length == 1) {
setState(() {
thumbnailPath = items.values.first.thumbnailPath;
});
}
}
@override
Widget build(BuildContext context) {
String? subtitle;
if (widget.message.kind == MessageKind.textMessage) {
if (widget.message.contentJson != null) {
final content = MessageContent.fromJson(MessageKind.textMessage,
jsonDecode(widget.message.contentJson!) as Map);
if (content is TextMessageContent) {
subtitle = truncateString(content.text);
}
}
}
if (widget.message.kind == MessageKind.media) {
final content = MessageContent.fromJson(
MessageKind.media, jsonDecode(widget.message.contentJson!) as Map);
if (content is MediaMessageContent) {
subtitle = content.isVideo ? 'Video' : 'Image';
}
}
var username = 'You';
if (widget.message.messageOtherId != null) {
username = getContactDisplayName(widget.contact);
}
final color = getMessageColor(widget.message);
if (!widget.message.mediaStored) {
return Container(
padding: widget.showBorder
? const EdgeInsets.only(left: 10, right: 10)
: const EdgeInsets.symmetric(horizontal: 5),
decoration: (widget.showBorder)
? BoxDecoration(
border: Border(
left: BorderSide(
color: color,
width: 2,
),
),
)
: null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
username,
style: const TextStyle(fontWeight: FontWeight.bold),
),
if (subtitle != null) Text(subtitle)
],
),
);
}
return Container(
padding: const EdgeInsets.only(left: 10),
width: 200,
decoration: BoxDecoration(
border: Border(
left: BorderSide(
color: color,
width: 2,
),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
username,
style: const TextStyle(fontWeight: FontWeight.bold),
),
if (subtitle != null) Text(subtitle)
],
),
),
if (thumbnailPath != null)
SizedBox(
height: widget.showBorder ? 100 : 210,
child: Image.file(thumbnailPath!),
)
],
),
);
}
}

View file

@ -179,7 +179,9 @@ PieTheme getPieCanvasTheme(BuildContext context) {
iconColor: Theme.of(context).colorScheme.surfaceBright, iconColor: Theme.of(context).colorScheme.surfaceBright,
), ),
tooltipPadding: const EdgeInsets.all(20), tooltipPadding: const EdgeInsets.all(20),
overlayColor: const Color.fromARGB(69, 0, 0, 0), overlayColor: isDarkMode(context)
? const Color.fromARGB(69, 0, 0, 0)
: const Color.fromARGB(40, 0, 0, 0),
// spacing: 0, // spacing: 0,
tooltipTextStyle: const TextStyle( tooltipTextStyle: const TextStyle(
fontSize: 32, fontSize: 32,