add time to messages

This commit is contained in:
otsmr 2025-10-26 18:54:54 +01:00
parent b03bcfe6e1
commit edf4209448
11 changed files with 260 additions and 54 deletions

View file

@ -175,6 +175,11 @@
"close": "Schließen", "close": "Schließen",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"ok": "Ok", "ok": "Ok",
"now": "Jetzt",
"you": "Du",
"minutesShort": "Min.",
"image": "Bild",
"video": "Video",
"react": "Reagieren", "react": "Reagieren",
"reply": "Antworten", "reply": "Antworten",
"copy": "Kopieren", "copy": "Kopieren",

View file

@ -299,6 +299,11 @@
"disable": "Disable", "disable": "Disable",
"enable": "Enable", "enable": "Enable",
"cancel": "Cancel", "cancel": "Cancel",
"now": "Now",
"you": "You",
"minutesShort": "min.",
"image": "Image",
"video": "Video",
"react": "React", "react": "React",
"reply": "Reply", "reply": "Reply",
"copy": "Copy", "copy": "Copy",

View file

@ -1058,6 +1058,36 @@ abstract class AppLocalizations {
/// **'Cancel'** /// **'Cancel'**
String get 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. /// No description provided for @react.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -536,6 +536,21 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get cancel => 'Abbrechen'; 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 @override
String get react => 'Reagieren'; String get react => 'Reagieren';

View file

@ -531,6 +531,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get cancel => 'Cancel'; 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 @override
String get react => 'React'; String get react => 'React';

View file

@ -28,22 +28,17 @@ Color getMessageColor(Message message) {
} }
class ChatItem { class ChatItem {
const ChatItem._({this.message, this.date, this.time}); const ChatItem._({this.message, this.date});
factory ChatItem.date(DateTime date) { factory ChatItem.date(DateTime date) {
return ChatItem._(date: date); return ChatItem._(date: date);
} }
factory ChatItem.time(DateTime time) {
return ChatItem._(time: time);
}
factory ChatItem.message(Message message) { factory ChatItem.message(Message message) {
return ChatItem._(message: message); return ChatItem._(message: message);
} }
final Message? message; final Message? message;
final DateTime? date; final DateTime? date;
final DateTime? time;
bool get isMessage => message != null; bool get isMessage => message != null;
bool get isDate => date != null; bool get isDate => date != null;
bool get isTime => time != null;
} }
/// Displays detailed information about a SampleItem. /// Displays detailed information about a SampleItem.
@ -143,9 +138,6 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
msg.createdAt.year != lastDate.year) { msg.createdAt.year != lastDate.year) {
chatItems.add(ChatItem.date(msg.createdAt)); chatItems.add(ChatItem.date(msg.createdAt));
lastDate = 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)); chatItems.add(ChatItem.message(msg));
} }
@ -276,7 +268,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
padding: EdgeInsetsGeometry.only(top: 10), padding: EdgeInsetsGeometry.only(top: 10),
); );
} }
if (messages[i].isDate || messages[i].isTime) { if (messages[i].isDate) {
return ChatDateChip( return ChatDateChip(
item: messages[i], item: messages[i],
); );
@ -295,10 +287,14 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
scale: (focusedScrollItem == i) ? 1.05 : 1, scale: (focusedScrollItem == i) ? 1.05 : 1,
child: ChatListEntry( child: ChatListEntry(
key: Key(chatMessage.messageId), key: Key(chatMessage.messageId),
chatMessage, message: messages[i].message!,
group, nextMessage:
galleryItems, (i > 0) ? messages[i - 1].message : null,
isLastMessageFromSameUser(messages, i), prevMessage: ((i + 1) < messages.length)
? messages[i + 1].message
: null,
group: group,
galleryItems: galleryItems,
scrollToMessage: scrollToMessage, scrollToMessage: scrollToMessage,
onResponseTriggered: () { onResponseTriggered: () {
setState(() { setState(() {
@ -403,22 +399,3 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
); );
} }
} }
bool isLastMessageFromSameUser(List<ChatItem> 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();
}

View file

@ -9,10 +9,8 @@ class ChatDateChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final formattedDate = item.isTime final formattedDate =
? DateFormat.Hm(Localizations.localeOf(context).toLanguageTag()) '${DateFormat.Hm(Localizations.localeOf(context).toLanguageTag()).format(item.date!)}\n${DateFormat.yMd(Localizations.localeOf(context).toLanguageTag()).format(item.date!)}';
.format(item.time!)
: '${DateFormat.Hm(Localizations.localeOf(context).toLanguageTag()).format(item.date!)}\n${DateFormat.yMd(Localizations.localeOf(context).toLanguageTag()).format(item.date!)}';
return Center( return Center(
child: Container( child: Container(
@ -23,6 +21,7 @@ class ChatDateChip extends StatelessWidget {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
margin: const EdgeInsets.only(bottom: 20),
child: Text( child: Text(
formattedDate, formattedDate,
textAlign: TextAlign.center, textAlign: TextAlign.center,

View file

@ -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'; import 'package:twonly/src/views/chats/chat_messages_components/response_container.dart';
class ChatListEntry extends StatefulWidget { class ChatListEntry extends StatefulWidget {
const ChatListEntry( const ChatListEntry({
this.message, required this.group,
this.group, required this.galleryItems,
this.galleryItems, required this.prevMessage,
this.lastMessageFromSameUser, { required this.message,
required this.nextMessage,
required this.onResponseTriggered, required this.onResponseTriggered,
required this.scrollToMessage, required this.scrollToMessage,
super.key, super.key,
}); });
final Message? prevMessage;
final Message? nextMessage;
final Message message; final Message message;
final Group group; final Group group;
final bool lastMessageFromSameUser;
final List<MemoryItem> galleryItems; final List<MemoryItem> galleryItems;
final void Function(String) scrollToMessage; final void Function(String) scrollToMessage;
final void Function() onResponseTriggered; final void Function() onResponseTriggered;
@ -70,12 +72,16 @@ class _ChatListEntryState extends State<ChatListEntry> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final right = widget.message.senderId == null; final right = widget.message.senderId == null;
final (padding, borderRadius) = getMessageLayout(
widget.message,
widget.prevMessage,
widget.nextMessage,
);
return Align( return Align(
alignment: right ? Alignment.centerRight : Alignment.centerLeft, alignment: right ? Alignment.centerRight : Alignment.centerLeft,
child: Padding( child: Padding(
padding: widget.lastMessageFromSameUser padding: padding,
? const EdgeInsets.only(top: 5, right: 10, left: 10)
: const EdgeInsets.only(top: 5, bottom: 20, right: 10, left: 10),
child: MessageContextMenu( child: MessageContextMenu(
message: widget.message, message: widget.message,
onResponseTriggered: widget.onResponseTriggered, onResponseTriggered: widget.onResponseTriggered,
@ -96,10 +102,13 @@ class _ChatListEntryState extends State<ChatListEntry> {
msg: widget.message, msg: widget.message,
group: widget.group, group: widget.group,
mediaService: mediaService, mediaService: mediaService,
borderRadius: borderRadius,
scrollToMessage: widget.scrollToMessage, scrollToMessage: widget.scrollToMessage,
child: (widget.message.type == MessageType.text) child: (widget.message.type == MessageType.text)
? ChatTextEntry( ? ChatTextEntry(
message: widget.message, message: widget.message,
nextMessage: widget.nextMessage,
borderRadius: borderRadius,
) )
: (mediaService == null) : (mediaService == null)
? null ? null
@ -128,3 +137,57 @@ class _ChatListEntryState extends State<ChatListEntry> {
); );
} }
} }
(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),
)
);
}

View file

@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; 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/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/chats/chat_messages.view.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/better_text.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 { class ChatTextEntry extends StatelessWidget {
const ChatTextEntry({ const ChatTextEntry({
required this.message, required this.message,
required this.nextMessage,
required this.borderRadius,
super.key, super.key,
}); });
final Message message; final Message message;
final Message? nextMessage;
final BorderRadius borderRadius;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -27,17 +34,98 @@ class ChatTextEntry extends StatelessWidget {
child: EmojiAnimation(emoji: text), child: EmojiAnimation(emoji: text),
); );
} }
final displayTime = !combineTextMessageWithNext(message, nextMessage);
return Container( return Container(
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8, 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( decoration: BoxDecoration(
color: color:
message.quotesMessageId == null ? getMessageColor(message) : null, 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), 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),
),
),
)
],
),
); );
} }
} }
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);
}
}

View file

@ -14,6 +14,7 @@ class ResponseContainer extends StatefulWidget {
required this.child, required this.child,
required this.scrollToMessage, required this.scrollToMessage,
required this.mediaService, required this.mediaService,
required this.borderRadius,
super.key, super.key,
}); });
@ -21,6 +22,7 @@ class ResponseContainer extends StatefulWidget {
final Widget? child; final Widget? child;
final Group group; final Group group;
final MediaFileService? mediaService; final MediaFileService? mediaService;
final BorderRadius borderRadius;
final void Function(String) scrollToMessage; final void Function(String) scrollToMessage;
@override @override
@ -69,7 +71,7 @@ class _ResponseContainerState extends State<ResponseContainer> {
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: getMessageColor(widget.msg), color: getMessageColor(widget.msg),
borderRadius: BorderRadius.circular(12), borderRadius: widget.borderRadius,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -157,11 +159,12 @@ class _ResponsePreviewState extends State<ResponsePreview> {
} }
} }
if (message!.type == MessageType.media && mediaService != null) { if (message!.type == MessageType.media && mediaService != null) {
subtitle = subtitle = mediaService!.mediaFile.type == MediaType.video
mediaService!.mediaFile.type == MediaType.video ? 'Video' : 'Image'; ? context.lang.video
: context.lang.image;
} }
var username = 'You'; var username = context.lang.you;
if (message!.senderId != null) { if (message!.senderId != null) {
username = message!.senderId.toString(); username = message!.senderId.toString();
} }

View file

@ -51,13 +51,19 @@ class BetterText extends StatelessWidget {
} }
if (lastMatchEnd < text.length) { if (lastMatchEnd < text.length) {
spans.add(TextSpan(text: text.substring(lastMatchEnd))); spans.add(
TextSpan(
text: text.substring(lastMatchEnd),
),
);
} }
return Text.rich( return Text.rich(
TextSpan( TextSpan(
children: spans, children: spans,
), ),
softWrap: true,
overflow: TextOverflow.visible,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 17, fontSize: 17,