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",
"cancel": "Abbrechen",
"ok": "Ok",
"now": "Jetzt",
"you": "Du",
"minutesShort": "Min.",
"image": "Bild",
"video": "Video",
"react": "Reagieren",
"reply": "Antworten",
"copy": "Kopieren",

View file

@ -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",

View file

@ -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:

View file

@ -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';

View file

@ -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';

View file

@ -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<ChatMessagesView> {
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<ChatMessagesView> {
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<ChatMessagesView> {
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<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
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,

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';
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<MemoryItem> galleryItems;
final void Function(String) scrollToMessage;
final void Function() onResponseTriggered;
@ -70,12 +72,16 @@ class _ChatListEntryState extends State<ChatListEntry> {
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<ChatListEntry> {
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<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: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);
}
}

View file

@ -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<ResponseContainer> {
),
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<ResponsePreview> {
}
}
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();
}

View file

@ -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,