diff --git a/lib/src/localization/app_de.arb b/lib/src/localization/app_de.arb index 88444e2..6ca346c 100644 --- a/lib/src/localization/app_de.arb +++ b/lib/src/localization/app_de.arb @@ -175,6 +175,11 @@ "close": "Schließen", "cancel": "Abbrechen", "ok": "Ok", + "now": "Jetzt", + "you": "Du", + "minutesShort": "Min.", + "image": "Bild", + "video": "Video", "react": "Reagieren", "reply": "Antworten", "copy": "Kopieren", diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index 63ccda9..0e6e0dc 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -299,6 +299,11 @@ "disable": "Disable", "enable": "Enable", "cancel": "Cancel", + "now": "Now", + "you": "You", + "minutesShort": "min.", + "image": "Image", + "video": "Video", "react": "React", "reply": "Reply", "copy": "Copy", diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 5f34009..443e715 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -1058,6 +1058,36 @@ abstract class AppLocalizations { /// **'Cancel'** String get cancel; + /// No description provided for @now. + /// + /// In en, this message translates to: + /// **'Now'** + String get now; + + /// No description provided for @you. + /// + /// In en, this message translates to: + /// **'You'** + String get you; + + /// No description provided for @minutesShort. + /// + /// In en, this message translates to: + /// **'min.'** + String get minutesShort; + + /// No description provided for @image. + /// + /// In en, this message translates to: + /// **'Image'** + String get image; + + /// No description provided for @video. + /// + /// In en, this message translates to: + /// **'Video'** + String get video; + /// No description provided for @react. /// /// In en, this message translates to: diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 598a761..da18ff0 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -536,6 +536,21 @@ class AppLocalizationsDe extends AppLocalizations { @override String get cancel => 'Abbrechen'; + @override + String get now => 'Jetzt'; + + @override + String get you => 'Du'; + + @override + String get minutesShort => 'Min.'; + + @override + String get image => 'Bild'; + + @override + String get video => 'Video'; + @override String get react => 'Reagieren'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index e199353..32e6e2b 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -531,6 +531,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get cancel => 'Cancel'; + @override + String get now => 'Now'; + + @override + String get you => 'You'; + + @override + String get minutesShort => 'min.'; + + @override + String get image => 'Image'; + + @override + String get video => 'Video'; + @override String get react => 'React'; diff --git a/lib/src/views/chats/chat_messages.view.dart b/lib/src/views/chats/chat_messages.view.dart index 538d9b7..63f236a 100644 --- a/lib/src/views/chats/chat_messages.view.dart +++ b/lib/src/views/chats/chat_messages.view.dart @@ -28,22 +28,17 @@ Color getMessageColor(Message message) { } class ChatItem { - const ChatItem._({this.message, this.date, this.time}); + const ChatItem._({this.message, this.date}); factory ChatItem.date(DateTime date) { return ChatItem._(date: date); } - factory ChatItem.time(DateTime time) { - return ChatItem._(time: time); - } factory ChatItem.message(Message message) { return ChatItem._(message: message); } final Message? 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. @@ -143,9 +138,6 @@ class _ChatMessagesViewState extends State { msg.createdAt.year != lastDate.year) { chatItems.add(ChatItem.date(msg.createdAt)); lastDate = msg.createdAt; - } else if (msg.createdAt.difference(lastDate).inMinutes >= 20) { - chatItems.add(ChatItem.time(msg.createdAt)); - lastDate = msg.createdAt; } chatItems.add(ChatItem.message(msg)); } @@ -276,7 +268,7 @@ class _ChatMessagesViewState extends State { padding: EdgeInsetsGeometry.only(top: 10), ); } - if (messages[i].isDate || messages[i].isTime) { + if (messages[i].isDate) { return ChatDateChip( item: messages[i], ); @@ -295,10 +287,14 @@ class _ChatMessagesViewState extends State { scale: (focusedScrollItem == i) ? 1.05 : 1, child: ChatListEntry( key: Key(chatMessage.messageId), - chatMessage, - group, - galleryItems, - isLastMessageFromSameUser(messages, i), + message: messages[i].message!, + nextMessage: + (i > 0) ? messages[i - 1].message : null, + prevMessage: ((i + 1) < messages.length) + ? messages[i + 1].message + : null, + group: group, + galleryItems: galleryItems, scrollToMessage: scrollToMessage, onResponseTriggered: () { setState(() { @@ -403,22 +399,3 @@ class _ChatMessagesViewState extends State { ); } } - -bool isLastMessageFromSameUser(List messages, int index) { - if (index <= 0) { - return true; // If there is no previous message, return true - } - return (messages[index - 1].message?.senderId == - messages[index].message?.senderId); -} - -double calculateNumberOfLines(String text, double width, double fontSize) { - final textPainter = TextPainter( - text: TextSpan( - text: text, - style: TextStyle(fontSize: fontSize), - ), - textDirection: TextDirection.ltr, - )..layout(maxWidth: width - 32); - return textPainter.computeLineMetrics().length.toDouble(); -} diff --git a/lib/src/views/chats/chat_messages_components/chat_date_chip.dart b/lib/src/views/chats/chat_messages_components/chat_date_chip.dart index bfaeaa4..4df7be7 100644 --- a/lib/src/views/chats/chat_messages_components/chat_date_chip.dart +++ b/lib/src/views/chats/chat_messages_components/chat_date_chip.dart @@ -9,10 +9,8 @@ class ChatDateChip extends StatelessWidget { @override Widget build(BuildContext context) { - final formattedDate = item.isTime - ? DateFormat.Hm(Localizations.localeOf(context).toLanguageTag()) - .format(item.time!) - : '${DateFormat.Hm(Localizations.localeOf(context).toLanguageTag()).format(item.date!)}\n${DateFormat.yMd(Localizations.localeOf(context).toLanguageTag()).format(item.date!)}'; + final formattedDate = + '${DateFormat.Hm(Localizations.localeOf(context).toLanguageTag()).format(item.date!)}\n${DateFormat.yMd(Localizations.localeOf(context).toLanguageTag()).format(item.date!)}'; return Center( child: Container( @@ -23,6 +21,7 @@ class ChatDateChip extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + margin: const EdgeInsets.only(bottom: 20), child: Text( formattedDate, textAlign: TextAlign.center, diff --git a/lib/src/views/chats/chat_messages_components/chat_list_entry.dart b/lib/src/views/chats/chat_messages_components/chat_list_entry.dart index 9c01271..e7a84d5 100644 --- a/lib/src/views/chats/chat_messages_components/chat_list_entry.dart +++ b/lib/src/views/chats/chat_messages_components/chat_list_entry.dart @@ -15,18 +15,20 @@ import 'package:twonly/src/views/chats/chat_messages_components/message_context_ import 'package:twonly/src/views/chats/chat_messages_components/response_container.dart'; class ChatListEntry extends StatefulWidget { - const ChatListEntry( - this.message, - this.group, - this.galleryItems, - this.lastMessageFromSameUser, { + const ChatListEntry({ + required this.group, + required this.galleryItems, + required this.prevMessage, + required this.message, + required this.nextMessage, required this.onResponseTriggered, required this.scrollToMessage, super.key, }); + final Message? prevMessage; + final Message? nextMessage; final Message message; final Group group; - final bool lastMessageFromSameUser; final List galleryItems; final void Function(String) scrollToMessage; final void Function() onResponseTriggered; @@ -70,12 +72,16 @@ class _ChatListEntryState extends State { Widget build(BuildContext context) { final right = widget.message.senderId == null; + final (padding, borderRadius) = getMessageLayout( + widget.message, + widget.prevMessage, + widget.nextMessage, + ); + 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), + padding: padding, child: MessageContextMenu( message: widget.message, onResponseTriggered: widget.onResponseTriggered, @@ -96,10 +102,13 @@ class _ChatListEntryState extends State { msg: widget.message, group: widget.group, mediaService: mediaService, + borderRadius: borderRadius, scrollToMessage: widget.scrollToMessage, child: (widget.message.type == MessageType.text) ? ChatTextEntry( message: widget.message, + nextMessage: widget.nextMessage, + borderRadius: borderRadius, ) : (mediaService == null) ? null @@ -128,3 +137,57 @@ class _ChatListEntryState extends State { ); } } + +(EdgeInsetsGeometry, BorderRadius) getMessageLayout( + Message message, + Message? prevMessage, + Message? nextMessage, +) { + var bottom = 30.0; + var top = 0.0; + + var topLeft = 12.0; + var topRight = 12.0; + var bottomRight = 12.0; + var bottomLeft = 12.0; + + if (nextMessage != null) { + if (message.senderId == nextMessage.senderId) { + bottom = 10; + } + } + + if (prevMessage != null) { + final combinesWidthNext = combineTextMessageWithNext(prevMessage, message); + if (combinesWidthNext) { + top = 1; + topLeft = 5.0; + } + } + + final combinesWidthNext = combineTextMessageWithNext(message, nextMessage); + if (combinesWidthNext) { + bottom = 1; + bottomLeft = 5.0; + } + + if (message.senderId == null) { + final tmp = topLeft; + topLeft = topRight; + topRight = tmp; + + final tmp2 = bottomLeft; + bottomLeft = bottomRight; + bottomRight = tmp2; + } + + return ( + EdgeInsets.only(top: top, bottom: bottom, right: 10, left: 10), + BorderRadius.only( + topLeft: Radius.circular(topLeft), + topRight: Radius.circular(topRight), + bottomRight: Radius.circular(bottomRight), + bottomLeft: Radius.circular(bottomLeft), + ) + ); +} diff --git a/lib/src/views/chats/chat_messages_components/chat_text_entry.dart b/lib/src/views/chats/chat_messages_components/chat_text_entry.dart index c730270..47fd528 100644 --- a/lib/src/views/chats/chat_messages_components/chat_text_entry.dart +++ b/lib/src/views/chats/chat_messages_components/chat_text_entry.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:intl/intl.dart' hide TextDirection; +import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/chats/chat_messages.view.dart'; import 'package:twonly/src/views/components/animate_icon.dart'; import 'package:twonly/src/views/components/better_text.dart'; @@ -7,10 +10,14 @@ import 'package:twonly/src/views/components/better_text.dart'; class ChatTextEntry extends StatelessWidget { const ChatTextEntry({ required this.message, + required this.nextMessage, + required this.borderRadius, super.key, }); final Message message; + final Message? nextMessage; + final BorderRadius borderRadius; @override Widget build(BuildContext context) { @@ -27,17 +34,98 @@ class ChatTextEntry extends StatelessWidget { child: EmojiAnimation(emoji: text), ); } + + final displayTime = !combineTextMessageWithNext(message, nextMessage); + return Container( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.8, ), - padding: const EdgeInsets.only(left: 10, top: 4, bottom: 4), + padding: const EdgeInsets.only(left: 10, top: 6, bottom: 6, right: 10), decoration: BoxDecoration( color: message.quotesMessageId == null ? getMessageColor(message) : null, - borderRadius: BorderRadius.circular(12), + borderRadius: borderRadius, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (measureTextWidth(text) > 270) + Expanded( + child: BetterText(text: text), + ) + else + BetterText(text: text), + if (displayTime) + Padding( + padding: const EdgeInsets.only(left: 6), + child: Text( + friendlyTime(context, message.createdAt), + style: TextStyle( + fontSize: 10, + color: Colors.white.withAlpha(150), + ), + ), + ) + ], ), - child: BetterText(text: text), ); } } + +double measureTextWidth( + String text, +) { + final tp = TextPainter( + text: TextSpan(text: text, style: const TextStyle(fontSize: 17)), + textDirection: TextDirection.ltr, + maxLines: 1, + )..layout(); + return tp.size.width; +} + +bool combineTextMessageWithNext(Message message, Message? nextMessage) { + if (nextMessage != null && nextMessage.content != null) { + if (nextMessage.senderId == message.senderId) { + if (nextMessage.type == MessageType.text && + message.type == MessageType.text) { + if (!EmojiAnimation.supported(nextMessage.content!)) { + final diff = + nextMessage.createdAt.difference(message.createdAt).inMinutes; + if (diff <= 1) { + return true; + } + } + } + } + } + return false; +} + +String friendlyTime(BuildContext context, DateTime dt) { + final now = DateTime.now(); + final diff = now.difference(dt); + + if (diff.inMinutes >= 0 && diff.inMinutes < 60) { + final minutes = diff.inMinutes == 0 ? 1 : diff.inMinutes; + if (minutes <= 1) { + return context.lang.now; + } + return '$minutes ${context.lang.minutesShort}'; + } + + // Determine 24h vs 12h from system/local settings + final use24Hour = MediaQuery.of(context).alwaysUse24HourFormat; + + if (!use24Hour) { + // 12-hour format with locale-aware AM/PM + final format = DateFormat.jm(Localizations.localeOf(context).toString()); + return format.format(dt); + } else { + // 24-hour HH:mm, locale-aware + final format = DateFormat.Hm(Localizations.localeOf(context).toString()); + return format.format(dt); + } +} diff --git a/lib/src/views/chats/chat_messages_components/response_container.dart b/lib/src/views/chats/chat_messages_components/response_container.dart index b46d6fd..ca4b9bb 100644 --- a/lib/src/views/chats/chat_messages_components/response_container.dart +++ b/lib/src/views/chats/chat_messages_components/response_container.dart @@ -14,6 +14,7 @@ class ResponseContainer extends StatefulWidget { required this.child, required this.scrollToMessage, required this.mediaService, + required this.borderRadius, super.key, }); @@ -21,6 +22,7 @@ class ResponseContainer extends StatefulWidget { final Widget? child; final Group group; final MediaFileService? mediaService; + final BorderRadius borderRadius; final void Function(String) scrollToMessage; @override @@ -69,7 +71,7 @@ class _ResponseContainerState extends State { ), decoration: BoxDecoration( color: getMessageColor(widget.msg), - borderRadius: BorderRadius.circular(12), + borderRadius: widget.borderRadius, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -157,11 +159,12 @@ class _ResponsePreviewState extends State { } } if (message!.type == MessageType.media && mediaService != null) { - subtitle = - mediaService!.mediaFile.type == MediaType.video ? 'Video' : 'Image'; + subtitle = mediaService!.mediaFile.type == MediaType.video + ? context.lang.video + : context.lang.image; } - var username = 'You'; + var username = context.lang.you; if (message!.senderId != null) { username = message!.senderId.toString(); } diff --git a/lib/src/views/components/better_text.dart b/lib/src/views/components/better_text.dart index 2373f9f..d05d174 100644 --- a/lib/src/views/components/better_text.dart +++ b/lib/src/views/components/better_text.dart @@ -51,13 +51,19 @@ class BetterText extends StatelessWidget { } if (lastMatchEnd < text.length) { - spans.add(TextSpan(text: text.substring(lastMatchEnd))); + spans.add( + TextSpan( + text: text.substring(lastMatchEnd), + ), + ); } return Text.rich( TextSpan( children: spans, ), + softWrap: true, + overflow: TextOverflow.visible, style: const TextStyle( color: Colors.white, fontSize: 17,