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.openedAt.isBiggerThanValue(
DateTime.now().subtract(const Duration(days: 1)))))
..orderBy([(t) => OrderingTerm.desc(t.sendAt)]))
..orderBy([(t) => OrderingTerm.asc(t.sendAt)]))
.watch();
}

View file

@ -34,7 +34,7 @@ class MemoryItem {
final isSend = message.messageOtherId == null;
final id = message.mediaUploadId ?? message.messageId;
final basePath = await send.getMediaFilePath(
isSend ? message.mediaUploadId! : message.messageId,
id,
isSend ? 'send' : 'received',
);
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/utils/misc.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/initialsavatar.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);
}
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.
class ChatMessagesView extends StatefulWidget {
const ChatMessagesView(this.contact, {super.key});
@ -48,9 +75,8 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
String currentInputText = '';
late StreamSubscription<Contact?> userSub;
late StreamSubscription<List<Message>> messageSub;
List<Message> messages = [];
List<ChatItem> messages = [];
List<MemoryItem> galleryItems = [];
Map<int, List<Message>> textReactionsToMessageId = {};
Map<int, List<Message>> emojiReactionsToMessageId = {};
Message? responseToMessage;
GlobalKey verifyShieldKey = GlobalKey();
@ -92,61 +118,85 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
final msgStream =
twonlyDB.messagesDao.watchAllMessagesFrom(widget.contact.userId);
messageSub = msgStream.listen((msgs) async {
messageSub = msgStream.listen((newMessages) async {
// if (!context.mounted) return;
if (Platform.isAndroid) {
await flutterLocalNotificationsPlugin.cancel(widget.contact.userId);
} else {
await flutterLocalNotificationsPlugin.cancelAll();
}
final displayedMessages = <Message>[];
// should be cleared
final tmpTextReactionsToMessageId = <int, List<Message>>{};
final chatItems = <ChatItem>[];
final storedMediaFiles = <Message>[];
DateTime? lastDate;
final tmpEmojiReactionsToMessageId = <int, List<Message>>{};
final openedMessageOtherIds = <int>[];
final messageOtherMessageIdToMyMessageId = <int, int>{};
final messageIdToMessage = <int, Message>{};
/// there is probably a better way...
for (final msg in msgs) {
for (final msg in newMessages) {
if (msg.messageOtherId != null) {
messageOtherMessageIdToMyMessageId[msg.messageOtherId!] =
msg.messageId;
}
messageIdToMessage[msg.messageId] = msg;
}
for (final msg in msgs) {
for (final msg in newMessages) {
if (msg.kind == MessageKind.textMessage &&
msg.messageOtherId != null &&
msg.openedAt == null) {
openedMessageOtherIds.add(msg.messageOtherId!);
}
Message? responseTo;
if (msg.kind == MessageKind.media && msg.mediaStored) {
storedMediaFiles.add(msg);
}
final responseId = msg.responseToMessageId ??
messageOtherMessageIdToMyMessageId[msg.responseToOtherMessageId];
var isReaction = false;
if (responseId != null) {
var added = false;
responseTo = messageIdToMessage[responseId];
final content = MessageContent.fromJson(
msg.kind,
jsonDecode(msg.contentJson!) as Map,
);
if (content is TextMessageContent) {
if (content.text.isNotEmpty && !isEmoji(content.text)) {
added = true;
tmpTextReactionsToMessageId
if (isEmoji(content.text)) {
isReaction = true;
tmpEmojiReactionsToMessageId
.putIfAbsent(responseId, () => [])
.add(msg);
}
}
if (!added) {
if (msg.kind == MessageKind.reopenedMedia) {
isReaction = true;
tmpEmojiReactionsToMessageId
.putIfAbsent(responseId, () => [])
.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);
setState(() {
textReactionsToMessageId = tmpTextReactionsToMessageId;
emojiReactionsToMessageId = tmpEmojiReactionsToMessageId;
messages = displayedMessages;
messages = chatItems.reversed.toList();
});
final filteredMediaFiles = displayedMessages
.where((x) => x.kind == MessageKind.media && x.mediaStored)
.toList()
.reversed
.toList();
final items = await MemoryItem.convertFromMessages(filteredMediaFiles);
final items = await MemoryItem.convertFromMessages(storedMediaFiles);
galleryItems = items.values.toList();
setState(() {});
});
@ -204,56 +248,6 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
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
Widget build(BuildContext context) {
return GestureDetector(
@ -296,70 +290,37 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
children: [
Expanded(
child: ListView.builder(
itemCount: messages.length + 1,
reverse: true,
itemExtentBuilder: (index, dimensions) {
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;
},
itemCount: messages.length + 1,
itemBuilder: (context, i) {
if (i == 0) {
return Container(); // just a padding
if (i == messages.length) {
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(
children: [
Expanded(child: getResponsePreview(responseToMessage!)),
Expanded(
child: ResponsePreview(
message: responseToMessage!,
showBorder: true,
contact: user,
),
),
IconButton(
onPressed: () {
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) {
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 currentMessage = messages[index];
// Check if both messages have the same messageOtherId (or both are null)
return (lastMessage.messageOtherId == null &&
currentMessage.messageOtherId == null) ||
(lastMessage.messageOtherId != null &&
currentMessage.messageOtherId != null);
if (lastMessage.isMessage && currentMessage.isMessage) {
// Check if both messages have the same messageOtherId (or both are null)
return (lastMessage.message!.message.messageOtherId == null &&
currentMessage.message!.message.messageOtherId == null) ||
(lastMessage.message!.message.messageOtherId != null &&
currentMessage.message!.message.messageOtherId != null);
}
return false;
}
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
Widget build(BuildContext context) {
final color = getMessageColorFromType(
@ -68,53 +113,11 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
return GestureDetector(
key: reopenMediaFile,
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)),
);
}
},
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);
}
}
},
onDoubleTap: onDoubleTap,
onTap: widget.message.kind == MessageKind.media ? onTap : null,
child: SizedBox(
width: 150,
height: widget.message.mediaStored ? 271 : null,
child: Align(
alignment: Alignment.centerRight,
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:twonly/src/database/twonly_database.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';
class ReactionRow extends StatefulWidget {
@ -26,7 +27,7 @@ class _ReactionRowState extends State<ReactionRow> {
final children = <Widget>[];
var hasOneTextReaction = false;
var hasOneReopened = false;
for (final reaction in widget.otherReactions) {
for (final reaction in widget.otherReactions.reversed) {
final content = MessageContent.fromJson(
reaction.kind, jsonDecode(reaction.contentJson!) as Map);
@ -34,15 +35,15 @@ class _ReactionRowState extends State<ReactionRow> {
if (hasOneReopened) continue;
hasOneReopened = true;
children.add(
const Expanded(
Expanded(
child: Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: EdgeInsets.only(right: 3),
padding: const EdgeInsets.only(right: 3),
child: FaIcon(
FontAwesomeIcons.repeat,
size: 12,
color: Colors.white,
color: isDarkMode(context) ? Colors.white : Colors.black,
),
),
),
@ -78,7 +79,7 @@ class _ReactionRowState extends State<ReactionRow> {
return Row(
mainAxisAlignment: widget.message.messageOtherId == null
? MainAxisAlignment.start
? MainAxisAlignment.end
: MainAxisAlignment.end,
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';
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 Message message;
final bool hasReaction;
@override
Widget build(BuildContext context) {
@ -28,9 +34,12 @@ class ChatTextEntry extends StatelessWidget {
constraints: BoxConstraints(
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(
color: getMessageColor(message),
color: message.responseToMessageId == null
? getMessageColor(message)
: null,
borderRadius: BorderRadius.circular(12),
),
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
Widget build(BuildContext context) {
if (galleryItemIndex == null) {
if (!widget.message.mediaStored) {
return Container(
constraints: const BoxConstraints(
minHeight: 39,
@ -138,10 +138,12 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
color: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: MemoriesItemThumbnail(
galleryItem: widget.galleryItems[galleryItemIndex!],
onTap: onTap,
),
child: galleryItemIndex != null
? MemoriesItemThumbnail(
galleryItem: widget.galleryItems[galleryItemIndex!],
onTap: onTap,
)
: null,
);
}
}

View file

@ -3,18 +3,7 @@
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 MessageActions extends StatefulWidget {
const MessageActions({
@ -70,11 +59,7 @@ class _SlidingResponseWidgetState extends State<MessageActions> {
child: GestureDetector(
onHorizontalDragUpdate: _onHorizontalDragUpdate,
onHorizontalDragEnd: _onHorizontalDragEnd,
child: MessageContextMenu(
message: widget.message,
onResponseTriggered: widget.onResponseTriggered,
child: widget.child,
),
child: widget.child,
),
),
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,
),
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,
tooltipTextStyle: const TextStyle(
fontSize: 32,