twonly-app/lib/src/visual/views/chats/chat_messages.view.dart
otsmr 722466cc34
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
fix some ui issues
2026-07-13 23:04:49 +02:00

475 lines
16 KiB
Dart

import 'dart:async';
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.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.db.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/api/messages.api.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
import 'package:twonly/src/visual/themes/colors.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/animated_new_message.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/blink.component.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_group_action.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_list_entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_date_chip.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/in_chat_group_overview.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/message_input.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/response_container.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/typing_indicator.dart';
class ChatMessagesView extends StatefulWidget {
const ChatMessagesView(this.groupId, {super.key});
final String groupId;
@override
State<ChatMessagesView> createState() => _ChatMessagesViewState();
}
class _ChatMessagesViewState extends State<ChatMessagesView>
with WidgetsBindingObserver {
HashSet<int> alreadyReportedOpened = HashSet<int>();
bool _hasReceivedFirstMessageBatch = false;
final HashSet<String> _knownMessageIds = HashSet<String>();
final HashSet<String> _animateMessageIds = HashSet<String>();
StreamSubscription<Group?>? userSub;
StreamSubscription<List<Message>>? messageSub;
StreamSubscription<List<GroupHistory>>? groupActionsSub;
StreamSubscription<List<Contact>>? contactSub;
Group? _group;
Map<int, Contact> userIdToContact = {};
List<ChatItem> messages = [];
List<Message> allMessages = [];
List<GroupHistory> groupActions = [];
List<MemoryItem> galleryItems = [];
Message? quotesMessage;
GlobalKey verifyShieldKey = GlobalKey();
FocusNode? textFieldFocus;
final ItemScrollController itemScrollController = ItemScrollController();
int? focusedScrollItem;
bool _receiverDeletedAccount = false;
Timer? _nextTypingIndicator;
@override
void initState() {
super.initState();
textFieldFocus = FocusNode();
WidgetsBinding.instance.addObserver(this);
initStreams();
}
@override
void dispose() {
userSub?.cancel();
messageSub?.cancel();
contactSub?.cancel();
groupActionsSub?.cancel();
_nextTypingIndicator?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
bool _isViewActive() {
if (!mounted) return false;
return !AppState.isAppInBackground &&
(ModalRoute.of(context)?.isCurrent ?? false);
}
Future<void> initStreams() async {
final groupStream = twonlyDB.groupsDao.watchGroup(widget.groupId);
userSub = groupStream.listen((newGroup) {
if (newGroup == null) return;
if (!mounted) return;
setState(() {
_group = newGroup;
});
if (groupActionsSub == null) {
final actionsStream = twonlyDB.groupsDao.watchGroupActions(
newGroup.groupId,
);
groupActionsSub = actionsStream.listen((update) async {
groupActions = update;
await setMessages(allMessages, update);
});
final contactsStream = twonlyDB.contactsDao.watchAllContacts();
contactSub = contactsStream.listen((contacts) {
for (final contact in contacts) {
userIdToContact[contact.userId] = contact;
}
});
}
});
final msgStream = await twonlyDB.messagesDao.watchByGroupId(widget.groupId);
messageSub = msgStream.listen((update) async {
allMessages = update;
await setMessages(update, groupActions);
_hasReceivedFirstMessageBatch = true;
});
final groupContacts = await twonlyDB.groupsDao.getGroupContact(
widget.groupId,
);
if (groupContacts.length == 1) {
_receiverDeletedAccount = groupContacts.first.accountDeleted;
}
if (userService.currentUser.typingIndicators) {
unawaited(sendTypingIndication(widget.groupId, false));
_nextTypingIndicator = Timer.periodic(const Duration(seconds: 2), (
_,
) async {
if (_isViewActive()) {
await sendTypingIndication(widget.groupId, false);
}
});
}
}
Future<void> setMessages(
List<Message> newMessages,
List<GroupHistory> groupActions,
) async {
if (_isViewActive()) {
unawaited(flutterLocalNotificationsPlugin.cancelAll());
}
for (final msg in newMessages) {
if (_hasReceivedFirstMessageBatch &&
!_knownMessageIds.contains(msg.messageId) &&
msg.senderId == null) {
_animateMessageIds.add(msg.messageId);
}
_knownMessageIds.add(msg.messageId);
}
final chatItems = <ChatItem>[];
final storedMediaFiles = <Message>[];
DateTime? lastDate;
final openedMessages = <int, List<String>>{};
var groupHistoryIndex = 0;
for (final msg in newMessages) {
if (groupHistoryIndex < groupActions.length) {
for (; groupHistoryIndex < groupActions.length; groupHistoryIndex++) {
if (msg.createdAt.isAfter(groupActions[groupHistoryIndex].actionAt)) {
chatItems.add(
ChatItem.groupAction(groupActions[groupHistoryIndex]),
);
// groupHistoryIndex++;
} else {
break;
}
}
}
if (msg.type != MessageType.media.name &&
msg.senderId != null &&
msg.openedAt == null) {
if (openedMessages[msg.senderId!] == null) {
openedMessages[msg.senderId!] = [];
}
openedMessages[msg.senderId!]!.add(msg.messageId);
}
if (msg.type == MessageType.media.name && msg.mediaStored) {
storedMediaFiles.add(msg);
}
if (lastDate == null ||
msg.createdAt.day != lastDate.day ||
msg.createdAt.month != lastDate.month ||
msg.createdAt.year != lastDate.year) {
chatItems.add(ChatItem.date(msg.createdAt));
lastDate = msg.createdAt;
}
chatItems.add(ChatItem.message(msg));
}
if (groupHistoryIndex < groupActions.length) {
for (var i = groupHistoryIndex; i < groupActions.length; i++) {
chatItems.add(ChatItem.groupAction(groupActions[i]));
}
}
if (_isViewActive()) {
for (final contactId in openedMessages.keys) {
unawaited(
notifyContactAboutOpeningMessage(
contactId,
openedMessages[contactId]!,
),
);
}
}
final wasSentByMe =
_hasReceivedFirstMessageBatch &&
newMessages.isNotEmpty &&
newMessages.last.senderId == null;
if (!mounted) return;
setState(() {
messages = chatItems.reversed.toList();
});
if (wasSentByMe && itemScrollController.isAttached) {
unawaited(
itemScrollController.scrollTo(
index: 0,
duration: const Duration(milliseconds: 150),
),
);
}
final items = await MemoryItem.convertFromMessages(storedMediaFiles);
if (!mounted) return;
galleryItems = items.values.toList();
setState(() {});
}
Future<void> scrollToMessage(String messageId) async {
final index = messages.indexWhere(
(x) => x.isMessage && x.message!.messageId == messageId,
);
if (index == -1) return;
setState(() {
focusedScrollItem = index;
});
await itemScrollController.scrollTo(
index: index,
duration: const Duration(milliseconds: 300),
alignment: 0.5,
);
Future.delayed(const Duration(milliseconds: 300), () {
if (!context.mounted) return;
setState(() {
focusedScrollItem = null;
});
});
}
@override
Widget build(BuildContext context) {
if (_group == null) return Container();
final group = _group!;
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
appBar: AppBar(
title: GestureDetector(
onTap: () async {
if (group.isDirectChat) {
final member = await twonlyDB.groupsDao.getAllGroupMembers(
group.groupId,
);
if (!context.mounted) return;
if (member.isEmpty) return;
await context.push(
Routes.profileContact(member.first.contactId),
);
} else {
await context.push(Routes.profileGroup(group.groupId));
}
},
child: Row(
children: [
AvatarIcon(
group: group,
fontSize: 19,
),
const SizedBox(width: 10),
Expanded(
child: ColoredBox(
color: Colors.transparent,
child: Row(
children: [
Text(
substringBy(group.groupName, 20),
),
const SizedBox(width: 5),
VerificationBadgeComp(
key: verifyShieldKey,
group: group,
),
const SizedBox(width: 10),
FlameCounterWidget(groupId: group.groupId),
],
),
),
),
],
),
),
),
body: SafeArea(
child: Column(
children: [
Expanded(
child: Align(
alignment: Alignment.topCenter,
child: ScrollablePositionedList.builder(
shrinkWrap: true,
reverse: true,
itemCount: messages.length + 1 + 1,
itemScrollController: itemScrollController,
itemBuilder: (context, i) {
if (i == 0) {
return userService.currentUser.typingIndicators
? TypingIndicator(group: group)
: Container();
}
i -= 1;
if (i == messages.length) {
return Padding(
key: Key('overview_${group.groupId}'),
padding: const EdgeInsets.only(top: 10),
child: InChatGroupOverview(
group: group,
),
);
}
if (messages[i].isDate) {
return ChatDateChip(
item: messages[i],
);
} else if (messages[i].isGroupAction) {
return ChatGroupAction(
key: Key(messages[i].groupAction!.groupHistoryId),
action: messages[i].groupAction!,
);
} else {
final chatMessage = messages[i].message!;
return BlinkWidget(
key: Key('blink_${chatMessage.messageId}'),
enabled: focusedScrollItem == i,
child: AnimatedNewMessage(
key: Key('anim_${chatMessage.messageId}'),
messageId: chatMessage.messageId,
animateIds: _animateMessageIds,
child: ChatListEntry(
key: Key(chatMessage.messageId),
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,
userIdToContact: userIdToContact,
scrollToMessage: scrollToMessage,
onResponseTriggered: () {
setState(() {
quotesMessage = chatMessage;
});
textFieldFocus?.requestFocus();
},
),
),
);
}
},
),
),
),
if (quotesMessage != null)
Container(
padding: const EdgeInsets.only(
left: 20,
right: 20,
top: 10,
),
child: Row(
children: [
Expanded(
child: ResponsePreview(
message: quotesMessage,
showBorder: true,
group: group,
),
),
IconButton(
onPressed: () {
setState(() {
quotesMessage = null;
});
},
icon: const FaIcon(
FontAwesomeIcons.xmark,
size: 16,
),
),
],
),
),
if (!group.leftGroup && !_receiverDeletedAccount)
MessageInput(
group: group,
quotesMessage: quotesMessage,
textFieldFocus: textFieldFocus!,
onMessageSend: () {
setState(() {
quotesMessage = null;
});
},
),
if (_receiverDeletedAccount)
Text(context.lang.userDeletedAccount),
],
),
),
),
);
}
}
Color getMessageColor(bool isOther) {
return isOther ? DefaultColors.messageSelf : DefaultColors.messageOther;
}
class ChatItem {
const ChatItem._({
this.message,
this.date,
this.groupAction,
});
factory ChatItem.date(DateTime date) {
return ChatItem._(date: date);
}
factory ChatItem.message(Message message) {
return ChatItem._(message: message);
}
factory ChatItem.groupAction(GroupHistory groupAction) {
return ChatItem._(groupAction: groupAction);
}
final GroupHistory? groupAction;
final Message? message;
final DateTime? date;
bool get isMessage => message != null;
bool get isDate => date != null;
bool get isGroupAction => groupAction != null;
}