Performance improvements
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-08-24 01:13:31 +02:00
parent 74c20389c9
commit e8abfe421b
15 changed files with 744 additions and 277 deletions

View file

@ -36,6 +36,11 @@ class ContactsDao extends DatabaseAccessor<TwonlyDB> with _$ContactsDaoMixin {
return select(contacts)..where((t) => t.userId.equals(userId)); return select(contacts)..where((t) => t.userId.equals(userId));
} }
Stream<List<Contact>> watchContactsByIds(Set<int> userIds) {
if (userIds.isEmpty) return Stream.value(const []);
return (select(contacts)..where((t) => t.userId.isIn(userIds))).watch();
}
Future<Contact?> getContactById(int userId) async { Future<Contact?> getContactById(int userId) async {
return (select( return (select(
contacts, contacts,

View file

@ -86,9 +86,18 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
await into(groupHistories).insert(insertAction); await into(groupHistories).insert(insertAction);
} }
Stream<List<GroupHistory>> watchGroupActions(String groupId) { Stream<List<GroupHistory>> watchGroupActions(
String groupId, {
DateTime? since,
}) {
return (select(groupHistories) return (select(groupHistories)
..where((t) => t.groupId.equals(groupId)) ..where(
(t) =>
t.groupId.equals(groupId) &
(since == null
? const Constant(true)
: t.actionAt.isBiggerOrEqualValue(since)),
)
..orderBy([(t) => OrderingTerm.asc(t.actionAt)])) ..orderBy([(t) => OrderingTerm.asc(t.actionAt)]))
.watch(); .watch();
} }

View file

@ -166,6 +166,13 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
.watch(); .watch();
} }
Stream<List<MediaFile>> watchMediaFilesByIds(Set<String> mediaIds) {
if (mediaIds.isEmpty) return Stream.value(const []);
return (select(
mediaFiles,
)..where((file) => file.mediaId.isIn(mediaIds))).watch();
}
Stream<List<MediaFile>> watchMediaFilesForGroup(String groupId) { Stream<List<MediaFile>> watchMediaFilesForGroup(String groupId) {
final query = select(mediaFiles).join([ final query = select(mediaFiles).join([
innerJoin( innerJoin(

View file

@ -142,7 +142,10 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
.not() | .not() |
mediaFiles.downloadState.isNull()), mediaFiles.downloadState.isNull()),
) )
..orderBy([OrderingTerm.desc(messages.createdAt)]) ..orderBy([
OrderingTerm.desc(messages.createdAt),
OrderingTerm.desc(messages.messageId),
])
..limit(1); ..limit(1);
return query.map((row) => row.readTable(messages)).watchSingleOrNull(); return query.map((row) => row.readTable(messages)).watchSingleOrNull();
@ -180,7 +183,10 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
); );
} }
Future<Stream<List<Message>>> watchByGroupId(String groupId) async { Future<Stream<List<Message>>> watchByGroupId(
String groupId, {
int limit = 100,
}) async {
final group = await twonlyDB.groupsDao.getGroup(groupId); final group = await twonlyDB.groupsDao.getGroup(groupId);
final deletionTime = clock.now().subtract( final deletionTime = clock.now().subtract(
Duration( Duration(
@ -211,9 +217,61 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
.equals(DownloadState.reuploadRequested.name) .equals(DownloadState.reuploadRequested.name)
.not()))), .not()))),
) )
..orderBy([OrderingTerm.asc(messages.createdAt)]); ..orderBy([OrderingTerm.desc(messages.createdAt)])
..limit(limit);
return query.map((row) => row.readTable(messages)).watch(); return query
.map((row) => row.readTable(messages))
.watch()
.map((items) => items.reversed.toList());
}
Future<List<Message>> getMessagesBefore(
String groupId,
DateTime before, {
required String beforeMessageId,
int limit = 100,
}) async {
final group = await twonlyDB.groupsDao.getGroup(groupId);
final deletionTime = clock.now().subtract(
Duration(milliseconds: group!.deleteMessagesAfterMilliseconds),
);
final query =
select(messages).join([
leftOuterJoin(
mediaFiles,
mediaFiles.mediaId.equalsExp(messages.mediaId),
),
])
..where(
messages.groupId.equals(groupId) &
(messages.createdAt.isSmallerThanValue(before) |
(messages.createdAt.equals(before) &
messages.messageId.isSmallerThanValue(
beforeMessageId,
))) &
(messages.openedAt.isBiggerThanValue(deletionTime) |
messages.openedAt.isNull() |
messages.mediaStored.equals(true)) &
(messages.isDeletedFromSender.equals(true) |
(messages.type.equals(MessageType.text.name).not() &
messages.type.equals(MessageType.media.name).not()) |
(messages.type.equals(MessageType.text.name) &
messages.content.isNotNull()) |
(messages.type.equals(MessageType.media.name) &
messages.mediaId.isNotNull() &
(mediaFiles.downloadState.isNull() |
mediaFiles.downloadState
.equals(DownloadState.reuploadRequested.name)
.not()))),
)
..orderBy([
OrderingTerm.desc(messages.createdAt),
OrderingTerm.desc(messages.messageId),
])
..limit(limit);
final items = await query.map((row) => row.readTable(messages)).get();
return items.reversed.toList();
} }
Stream<List<(GroupMember, Contact)>> watchMembersByGroupId(String groupId) { Stream<List<(GroupMember, Contact)>> watchMembersByGroupId(String groupId) {
@ -628,9 +686,19 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
.watch(); .watch();
} }
Stream<List<MessageAction>> watchMessageActionsForGroup( Stream<List<MessageAction>> watchAcknowledgementsForMessages(
String groupId, Set<String> messageIds,
) { ) {
if (messageIds.isEmpty) return Stream.value(const []);
return (select(messageActions)..where(
(action) =>
action.messageId.isIn(messageIds) &
action.type.equals(MessageActionType.ackByUserAt.name),
))
.watch();
}
Stream<List<MessageAction>> watchMessageActionsForGroup(String groupId) {
final query = select(messageActions).join([ final query = select(messageActions).join([
innerJoin( innerJoin(
messages, messages,

View file

@ -108,6 +108,14 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
.watch(); .watch();
} }
Stream<List<Reaction>> watchReactionsForMessages(Set<String> messageIds) {
if (messageIds.isEmpty) return Stream.value(const []);
return (select(reactions)
..where((reaction) => reaction.messageId.isIn(messageIds))
..orderBy([(reaction) => OrderingTerm.desc(reaction.createdAt)]))
.watch();
}
Stream<List<Reaction>> watchReactionsForGroup(String groupId) { Stream<List<Reaction>> watchReactionsForGroup(String groupId) {
final query = final query =
select(reactions).join([ select(reactions).join([

View file

@ -296,11 +296,9 @@ class MediaFileService {
bool get imagePreviewAvailable => bool get imagePreviewAvailable =>
mediaFile.hasThumbnail || mediaFile.hasThumbnail ||
(thumbnailPath.existsSync() && thumbnailPath.lengthSync() > 0) ||
mediaFile.type == MediaType.audio || mediaFile.type == MediaType.audio ||
((mediaFile.type == MediaType.image || mediaFile.type == MediaType.gif) && ((mediaFile.type == MediaType.image || mediaFile.type == MediaType.gif) &&
storedPath.existsSync() && mediaFile.stored);
storedPath.lengthSync() > 0);
Future<void> storeMediaFile() async { Future<void> storeMediaFile() async {
Log.info('Storing media file ${mediaFile.mediaId}'); Log.info('Storing media file ${mediaFile.mediaId}');

View file

@ -6,7 +6,6 @@ import 'package:flutter_svg/svg.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/avatars.dart'; import 'package:twonly/src/utils/avatars.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:vector_graphics/vector_graphics.dart'; import 'package:vector_graphics/vector_graphics.dart';
class AvatarIcon extends StatefulWidget { class AvatarIcon extends StatefulWidget {
@ -34,6 +33,7 @@ class AvatarIcon extends StatefulWidget {
class _AvatarIconState extends State<AvatarIcon> { class _AvatarIconState extends State<AvatarIcon> {
List<Contact> _avatarContacts = []; List<Contact> _avatarContacts = [];
Set<int> _contactsWithPngAvatar = {};
String? _myAvatarPath; String? _myAvatarPath;
StreamSubscription<List<Contact>>? groupStream; StreamSubscription<List<Contact>>? groupStream;
@ -59,9 +59,24 @@ class _AvatarIconState extends State<AvatarIcon> {
_avatarContacts = contacts _avatarContacts = contacts
.where((contact) => contact.avatarSvgCompressed != null) .where((contact) => contact.avatarSvgCompressed != null)
.toList(); .toList();
unawaited(_refreshAvatarFiles());
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
Future<void> _refreshAvatarFiles() async {
final available = <int>{};
for (final contact in _avatarContacts) {
final file = avatarPNGFile(contact.userId);
// Async file access keeps avatar discovery off the UI thread.
// ignore: avoid_slow_async_io
if (await file.exists()) {
available.add(contact.userId);
}
}
if (!mounted) return;
setState(() => _contactsWithPngAvatar = available);
}
@override @override
void dispose() { void dispose() {
groupStream?.cancel(); groupStream?.cancel();
@ -79,18 +94,13 @@ class _AvatarIconState extends State<AvatarIcon> {
Widget getAvatarForContact(Contact contact) { Widget getAvatarForContact(Contact contact) {
final avatarFile = avatarPNGFile(contact.userId); final avatarFile = avatarPNGFile(contact.userId);
if (avatarFile.existsSync()) { if (_contactsWithPngAvatar.contains(contact.userId)) {
return Image.file( return Image.file(
avatarFile, avatarFile,
errorBuilder: errorBuilder, errorBuilder: errorBuilder,
); );
} }
Log.warn(
'PNG avatar file for contact ${contact.userId} does not exist. Generating in background.',
);
unawaited(createPushAvatars(forceForUserId: contact.userId));
if (contact.avatarSvgCompressed != null) { if (contact.avatarSvgCompressed != null) {
return SvgPicture.string( return SvgPicture.string(
getAvatarSvg(contact.avatarSvgCompressed!), getAvatarSvg(contact.avatarSvgCompressed!),
@ -119,6 +129,7 @@ class _AvatarIconState extends State<AvatarIcon> {
} }
} }
} }
unawaited(_refreshAvatarFiles());
setState(() {}); setState(() {});
}); });
} else if (widget.myAvatar) { } else if (widget.myAvatar) {
@ -132,6 +143,7 @@ class _AvatarIconState extends State<AvatarIcon> {
.listen((contact) { .listen((contact) {
if (contact != null && contact.avatarSvgCompressed != null) { if (contact != null && contact.avatarSvgCompressed != null) {
_avatarContacts = [contact]; _avatarContacts = [contact];
unawaited(_refreshAvatarFiles());
setState(() {}); setState(() {});
} }
}); });

View file

@ -13,7 +13,9 @@ import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/tables/messages.table.dart'; 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/model/memory_item.model.dart'; import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/services/api/messages.api.dart'; import 'package:twonly/src/services/api/messages.api.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
@ -36,6 +38,7 @@ class _MessageAnimationState {
bool hasReceivedFirstBatch = false; bool hasReceivedFirstBatch = false;
final HashSet<String> knownMessageIds = HashSet<String>(); final HashSet<String> knownMessageIds = HashSet<String>();
final HashSet<String> animateMessageIds = HashSet<String>(); final HashSet<String> animateMessageIds = HashSet<String>();
final HashSet<String> reportedOpenedMessageIds = HashSet<String>();
} }
class _ChatViewData { class _ChatViewData {
@ -46,10 +49,15 @@ class _ChatViewData {
List<ChatItem> chatItems = []; List<ChatItem> chatItems = [];
List<Message> allMessages = []; List<Message> allMessages = [];
List<Message> latestMessages = [];
List<Message> olderMessages = [];
Map<String, Message> messagesById = {}; Map<String, Message> messagesById = {};
List<GroupHistory> groupActions = []; List<GroupHistory> groupActions = [];
List<MemoryItem> galleryItems = []; List<MemoryItem> galleryItems = [];
Set<String> galleryMessageIds = {}; Set<String> galleryMessageIds = {};
Set<String> watchedMessageIds = {};
Set<int> watchedContactIds = {};
DateTime? watchedGroupActionsSince;
} }
class _ChatSubscriptions { class _ChatSubscriptions {
@ -83,11 +91,12 @@ class ChatMessagesView extends StatefulWidget {
class _ChatMessagesViewState extends State<ChatMessagesView> class _ChatMessagesViewState extends State<ChatMessagesView>
with WidgetsBindingObserver { with WidgetsBindingObserver {
HashSet<int> alreadyReportedOpened = HashSet<int>(); static const _messagePageSize = 100;
final _animationState = _MessageAnimationState(); final _animationState = _MessageAnimationState();
final _subscriptions = _ChatSubscriptions(); final _subscriptions = _ChatSubscriptions();
final _data = _ChatViewData(); final _data = _ChatViewData();
final ValueNotifier<int> _messageDataVersion = ValueNotifier(0);
Group? _group; Group? _group;
List<Contact> _groupContacts = []; List<Contact> _groupContacts = [];
@ -95,8 +104,12 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
GlobalKey verifyShieldKey = GlobalKey(); GlobalKey verifyShieldKey = GlobalKey();
FocusNode? textFieldFocus; FocusNode? textFieldFocus;
final ItemScrollController itemScrollController = ItemScrollController(); final ItemScrollController itemScrollController = ItemScrollController();
final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create();
int? focusedScrollItem; int? focusedScrollItem;
bool _receiverDeletedAccount = false; bool _receiverDeletedAccount = false;
Future<void>? _olderMessagesLoad;
bool _hasMoreMessages = true;
Timer? _nextTypingIndicator; Timer? _nextTypingIndicator;
@ -105,12 +118,15 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
super.initState(); super.initState();
textFieldFocus = FocusNode(); textFieldFocus = FocusNode();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
itemPositionsListener.itemPositions.addListener(_loadOlderWhenNeeded);
initStreams(); initStreams();
} }
@override @override
void dispose() { void dispose() {
_subscriptions.cancelAll(); _subscriptions.cancelAll();
_messageDataVersion.dispose();
itemPositionsListener.itemPositions.removeListener(_loadOlderWhenNeeded);
_nextTypingIndicator?.cancel(); _nextTypingIndicator?.cancel();
try { try {
textFieldFocus?.dispose(); textFieldFocus?.dispose();
@ -126,6 +142,10 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
(ModalRoute.of(context)?.isCurrent ?? false); (ModalRoute.of(context)?.isCurrent ?? false);
} }
void _notifyMessageDataChanged() {
_messageDataVersion.value++;
}
Future<void> initStreams() async { Future<void> initStreams() async {
final groupStream = twonlyDB.groupsDao.watchGroup(widget.groupId); final groupStream = twonlyDB.groupsDao.watchGroup(widget.groupId);
_subscriptions.group = groupStream.listen((newGroup) { _subscriptions.group = groupStream.listen((newGroup) {
@ -134,69 +154,16 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
setState(() { setState(() {
_group = newGroup; _group = newGroup;
}); });
if (_subscriptions.groupActions == null) {
final actionsStream = twonlyDB.groupsDao.watchGroupActions(
newGroup.groupId,
);
_subscriptions.groupActions = actionsStream.listen((update) async {
_data.groupActions = update;
await setMessages(_data.allMessages, update);
});
final contactsStream = twonlyDB.contactsDao.watchAllContacts();
_subscriptions.contacts = contactsStream.listen((contacts) {
final contactMap = <int, Contact>{};
for (final contact in contacts) {
contactMap[contact.userId] = contact;
}
if (mounted) setState(() => _data.contactsById = contactMap);
});
}
}); });
final msgStream = await twonlyDB.messagesDao.watchByGroupId(widget.groupId); final msgStream = await twonlyDB.messagesDao.watchByGroupId(widget.groupId);
_subscriptions.messages = msgStream.listen((update) async { _subscriptions.messages = msgStream.listen((update) async {
_data.allMessages = update; _data.latestMessages = update;
_data.messagesById = { if (_data.olderMessages.isEmpty) {
for (final message in update) message.messageId: message, _hasMoreMessages = update.length == _messagePageSize;
};
await setMessages(update, _data.groupActions);
_animationState.hasReceivedFirstBatch = true;
});
_subscriptions.media = twonlyDB.mediaFilesDao
.watchMediaFilesForGroup(widget.groupId)
.listen((mediaFiles) {
if (!mounted) return;
setState(
() => _data.mediaFilesById = {
for (final mediaFile in mediaFiles) mediaFile.mediaId: mediaFile,
},
);
});
_subscriptions.reactions = twonlyDB.reactionsDao
.watchReactionsForGroup(widget.groupId)
.listen((reactions) {
if (!mounted) return;
final byMessage = <String, List<Reaction>>{};
for (final reaction in reactions) {
byMessage.putIfAbsent(reaction.messageId, () => []).add(reaction);
} }
setState(() => _data.reactionsByMessageId = byMessage); await _applyLoadedMessages(reportOpened: true);
}); _animationState.hasReceivedFirstBatch = true;
_subscriptions.messageActions = twonlyDB.messagesDao
.watchMessageActionsForGroup(widget.groupId)
.listen((actions) {
if (!mounted) return;
setState(
() => _data.ackedMessageIds = actions
.where(
(action) => action.type == MessageActionType.ackByUserAt,
)
.map((action) => action.messageId)
.toSet(),
);
}); });
final groupContacts = await twonlyDB.groupsDao.getGroupContact( final groupContacts = await twonlyDB.groupsDao.getGroupContact(
@ -208,6 +175,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
_receiverDeletedAccount = _receiverDeletedAccount =
groupContacts.length == 1 && groupContacts.first.accountDeleted; groupContacts.length == 1 && groupContacts.first.accountDeleted;
}); });
_watchRelevantContacts();
} }
if (userService.currentUser.typingIndicators) { if (userService.currentUser.typingIndicators) {
@ -222,11 +190,172 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
} }
} }
Future<void> _applyLoadedMessages({required bool reportOpened}) async {
final byId = <String, Message>{
for (final message in _data.olderMessages) message.messageId: message,
for (final message in _data.latestMessages) message.messageId: message,
};
final loadedMessages = byId.values.toList()
..sort((a, b) {
final byTime = a.createdAt.compareTo(b.createdAt);
return byTime != 0 ? byTime : a.messageId.compareTo(b.messageId);
});
_data.allMessages = loadedMessages;
_data.messagesById = byId;
_watchGroupActionsForLoadedRange();
_watchLoadedMessageData();
_watchRelevantContacts();
await setMessages(
loadedMessages,
_data.groupActions,
reportOpened: reportOpened,
);
}
void _watchGroupActionsForLoadedRange() {
final since = _data.allMessages.firstOrNull?.createdAt;
if (_subscriptions.groupActions != null &&
_data.watchedGroupActionsSince == since) {
return;
}
_data.watchedGroupActionsSince = since;
unawaited(_subscriptions.groupActions?.cancel());
_subscriptions.groupActions = twonlyDB.groupsDao
.watchGroupActions(widget.groupId, since: since)
.listen((actions) async {
_data.groupActions = actions;
_watchRelevantContacts();
await setMessages(_data.allMessages, actions);
});
}
void _loadOlderWhenNeeded() {
if (_olderMessagesLoad != null || !_hasMoreMessages) return;
final positions = itemPositionsListener.itemPositions.value;
if (positions.isEmpty) return;
final oldestVisibleIndex = positions
.map((position) => position.index)
.reduce((a, b) => a > b ? a : b);
if (oldestVisibleIndex >= _data.chatItems.length - 10) {
unawaited(_loadOlderMessages());
}
}
Future<void> _loadOlderMessages() async {
if (_olderMessagesLoad case final pending?) return pending;
if (!_hasMoreMessages) return;
final load = _performLoadOlderMessages();
_olderMessagesLoad = load;
try {
await load;
} finally {
_olderMessagesLoad = null;
}
}
Future<void> _performLoadOlderMessages() async {
final oldestMessage = _data.allMessages.firstOrNull;
if (oldestMessage == null) return;
final older = await twonlyDB.messagesDao.getMessagesBefore(
widget.groupId,
oldestMessage.createdAt,
beforeMessageId: oldestMessage.messageId,
);
_hasMoreMessages = older.length == _messagePageSize;
_data.olderMessages = [...older, ..._data.olderMessages];
await _applyLoadedMessages(reportOpened: true);
}
void _watchLoadedMessageData() {
final messageIds = _data.messagesById.keys.toSet();
if (setEquals(_data.watchedMessageIds, messageIds)) return;
_data.watchedMessageIds = messageIds;
unawaited(_subscriptions.media?.cancel());
unawaited(_subscriptions.reactions?.cancel());
unawaited(_subscriptions.messageActions?.cancel());
final mediaIds = _data.allMessages
.map((message) => message.mediaId)
.whereType<String>()
.toSet();
_subscriptions.media = twonlyDB.mediaFilesDao
.watchMediaFilesByIds(mediaIds)
.listen((mediaFiles) {
if (!mounted) return;
_data.mediaFilesById = {
for (final mediaFile in mediaFiles) mediaFile.mediaId: mediaFile,
};
_notifyMessageDataChanged();
_updateGalleryItems(force: true);
});
_subscriptions.reactions = twonlyDB.reactionsDao
.watchReactionsForMessages(messageIds)
.listen((reactions) {
if (!mounted) return;
final byMessage = <String, List<Reaction>>{};
for (final reaction in reactions) {
byMessage.putIfAbsent(reaction.messageId, () => []).add(reaction);
}
_data.reactionsByMessageId = byMessage;
_notifyMessageDataChanged();
});
_subscriptions.messageActions = twonlyDB.messagesDao
.watchAcknowledgementsForMessages(messageIds)
.listen((actions) {
if (!mounted) return;
_data.ackedMessageIds = actions
.map((action) => action.messageId)
.toSet();
_notifyMessageDataChanged();
});
}
void _watchRelevantContacts() {
final contactIds = <int>{
..._groupContacts.map((contact) => contact.userId),
..._data.allMessages.map((message) => message.senderId).whereType<int>(),
..._data.groupActions
.expand((action) => [action.contactId, action.affectedContactId])
.whereType<int>(),
..._referencedContactIds(),
};
if (setEquals(_data.watchedContactIds, contactIds)) return;
_data.watchedContactIds = contactIds;
unawaited(_subscriptions.contacts?.cancel());
_subscriptions.contacts = twonlyDB.contactsDao
.watchContactsByIds(contactIds)
.listen((contacts) {
if (!mounted) return;
_data.contactsById = {
for (final contact in contacts) contact.userId: contact,
};
_notifyMessageDataChanged();
});
}
Iterable<int> _referencedContactIds() sync* {
for (final message in _data.allMessages) {
final bytes = message.additionalMessageData;
if (bytes == null) continue;
try {
final data = AdditionalMessageData.fromBuffer(bytes);
if (data.hasAskAboutUserId()) yield data.askAboutUserId.toInt();
for (final contact in data.contacts) {
yield contact.userId.toInt();
}
} catch (_) {
// Invalid additional data is handled by the corresponding bubble.
}
}
}
Future<void> setMessages( Future<void> setMessages(
List<Message> newMessages, List<Message> newMessages,
List<GroupHistory> groupActions, List<GroupHistory> groupActions, {
) async { bool reportOpened = false,
if (_isViewActive()) { }) async {
if (reportOpened && _isViewActive()) {
unawaited(flutterLocalNotificationsPlugin.cancelAll()); unawaited(flutterLocalNotificationsPlugin.cancelAll());
} }
@ -241,6 +370,12 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
final chatItems = <ChatItem>[]; final chatItems = <ChatItem>[];
final storedMediaFiles = <Message>[]; final storedMediaFiles = <Message>[];
final oldestLoadedAt = newMessages.firstOrNull?.createdAt;
final visibleGroupActions = oldestLoadedAt == null
? groupActions
: groupActions
.where((action) => !action.actionAt.isBefore(oldestLoadedAt))
.toList();
DateTime? lastDate; DateTime? lastDate;
@ -249,11 +384,17 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
var groupHistoryIndex = 0; var groupHistoryIndex = 0;
for (final msg in newMessages) { for (final msg in newMessages) {
if (groupHistoryIndex < groupActions.length) { if (groupHistoryIndex < visibleGroupActions.length) {
for (; groupHistoryIndex < groupActions.length; groupHistoryIndex++) { for (
if (msg.createdAt.isAfter(groupActions[groupHistoryIndex].actionAt)) { ;
groupHistoryIndex < visibleGroupActions.length;
groupHistoryIndex++
) {
if (msg.createdAt.isAfter(
visibleGroupActions[groupHistoryIndex].actionAt,
)) {
chatItems.add( chatItems.add(
ChatItem.groupAction(groupActions[groupHistoryIndex]), ChatItem.groupAction(visibleGroupActions[groupHistoryIndex]),
); );
// groupHistoryIndex++; // groupHistoryIndex++;
} else { } else {
@ -263,7 +404,8 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
} }
if (msg.type != MessageType.media.name && if (msg.type != MessageType.media.name &&
msg.senderId != null && msg.senderId != null &&
msg.openedAt == null) { msg.openedAt == null &&
!_animationState.reportedOpenedMessageIds.contains(msg.messageId)) {
if (openedMessages[msg.senderId!] == null) { if (openedMessages[msg.senderId!] == null) {
openedMessages[msg.senderId!] = []; openedMessages[msg.senderId!] = [];
} }
@ -283,14 +425,17 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
} }
chatItems.add(ChatItem.message(msg)); chatItems.add(ChatItem.message(msg));
} }
if (groupHistoryIndex < groupActions.length) { if (groupHistoryIndex < visibleGroupActions.length) {
for (var i = groupHistoryIndex; i < groupActions.length; i++) { for (var i = groupHistoryIndex; i < visibleGroupActions.length; i++) {
chatItems.add(ChatItem.groupAction(groupActions[i])); chatItems.add(ChatItem.groupAction(visibleGroupActions[i]));
} }
} }
if (_isViewActive()) { if (reportOpened && _isViewActive()) {
for (final contactId in openedMessages.keys) { for (final contactId in openedMessages.keys) {
_animationState.reportedOpenedMessageIds.addAll(
openedMessages[contactId]!,
);
unawaited( unawaited(
notifyContactAboutOpeningMessage( notifyContactAboutOpeningMessage(
contactId, contactId,
@ -306,9 +451,8 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
newMessages.last.senderId == null; newMessages.last.senderId == null;
if (!mounted) return; if (!mounted) return;
setState(() {
_data.chatItems = chatItems.reversed.toList(); _data.chatItems = chatItems.reversed.toList();
}); _notifyMessageDataChanged();
if (wasSentByMe) { if (wasSentByMe) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@ -326,26 +470,56 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
}); });
} }
final galleryMessageIds = storedMediaFiles _updateGalleryItems(messages: storedMediaFiles);
}
void _updateGalleryItems({List<Message>? messages, bool force = false}) {
final storedMediaMessages =
messages ??
_data.allMessages
.where(
(message) =>
message.type == MessageType.media.name && message.mediaStored,
)
.toList();
final messageIds = storedMediaMessages
.map((message) => message.messageId) .map((message) => message.messageId)
.toSet(); .toSet();
if (setEquals(_data.galleryMessageIds, galleryMessageIds)) return; if (!force && setEquals(_data.galleryMessageIds, messageIds)) return;
final items = await MemoryItem.convertFromMessages(storedMediaFiles);
final items = <String, MemoryItem>{};
for (final message in storedMediaMessages) {
final mediaFile = _data.mediaFilesById[message.mediaId];
if (mediaFile == null) continue;
final mediaService = MediaFileService(mediaFile);
if (!mediaService.imagePreviewAvailable) continue;
items
.putIfAbsent(
mediaFile.mediaId,
() => MemoryItem(mediaService: mediaService, messages: []),
)
.messages
.add(message);
}
if (!mounted) return; if (!mounted) return;
setState(() { _data.galleryMessageIds = messageIds;
_data.galleryMessageIds = galleryMessageIds;
_data.galleryItems = items.values.toList(); _data.galleryItems = items.values.toList();
}); _notifyMessageDataChanged();
} }
Future<void> scrollToMessage(String messageId) async { Future<void> scrollToMessage(String messageId) async {
final index = _data.chatItems.indexWhere( var index = _data.chatItems.indexWhere(
(x) => x.isMessage && x.message!.messageId == messageId, (x) => x.isMessage && x.message!.messageId == messageId,
); );
while (index == -1 && _hasMoreMessages) {
await _loadOlderMessages();
index = _data.chatItems.indexWhere(
(item) => item.isMessage && item.message!.messageId == messageId,
);
}
if (index == -1) return; if (index == -1) return;
setState(() {
focusedScrollItem = index; focusedScrollItem = index;
}); _notifyMessageDataChanged();
await itemScrollController.scrollTo( await itemScrollController.scrollTo(
index: index, index: index,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
@ -353,9 +527,8 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
); );
Future.delayed(const Duration(milliseconds: 300), () { Future.delayed(const Duration(milliseconds: 300), () {
if (!context.mounted) return; if (!context.mounted) return;
setState(() {
focusedScrollItem = null; focusedScrollItem = null;
}); _notifyMessageDataChanged();
}); });
} }
@ -411,20 +584,9 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
FlameCounterWidget(group: group), FlameCounterWidget(group: group),
], ],
), ),
if (group.isDirectChat) if (group.isDirectChat && _groupContacts.isNotEmpty)
StreamBuilder<List<Contact>>( ContactLabels(
stream: twonlyDB.groupsDao.watchGroupContact( contactId: _groupContacts.first.userId,
group.groupId,
),
builder: (context, snapshot) {
final contacts = snapshot.data ?? [];
if (contacts.isEmpty) {
return const SizedBox.shrink();
}
return ContactLabels(
contactId: contacts.first.userId,
);
},
), ),
], ],
), ),
@ -438,7 +600,9 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
child: Column( child: Column(
children: [ children: [
Expanded( Expanded(
child: Align( child: ValueListenableBuilder<int>(
valueListenable: _messageDataVersion,
builder: (context, _, _) => Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: ChatMessageActionScope( child: ChatMessageActionScope(
ackedMessageIds: _data.ackedMessageIds, ackedMessageIds: _data.ackedMessageIds,
@ -446,6 +610,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
reverse: true, reverse: true,
itemCount: _data.chatItems.length + 1 + 1, itemCount: _data.chatItems.length + 1 + 1,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
itemBuilder: (context, i) { itemBuilder: (context, i) {
if (i == 0) { if (i == 0) {
return userService.currentUser.typingIndicators return userService.currentUser.typingIndicators
@ -489,7 +654,8 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
nextMessage: (i > 0) nextMessage: (i > 0)
? _data.chatItems[i - 1].message ? _data.chatItems[i - 1].message
: null, : null,
prevMessage: ((i + 1) < _data.chatItems.length) prevMessage:
((i + 1) < _data.chatItems.length)
? _data.chatItems[i + 1].message ? _data.chatItems[i + 1].message
: null, : null,
group: group, group: group,
@ -497,7 +663,8 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
userIdToContact: _data.contactsById, userIdToContact: _data.contactsById,
mediaFile: chatMessage.mediaId == null mediaFile: chatMessage.mediaId == null
? null ? null
: _data.mediaFilesById[chatMessage.mediaId], : _data.mediaFilesById[chatMessage
.mediaId],
reactions: reactions:
_data.reactionsByMessageId[chatMessage _data.reactionsByMessageId[chatMessage
.messageId] ?? .messageId] ??
@ -521,6 +688,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
), ),
), ),
), ),
),
if (quotesMessage != null) if (quotesMessage != null)
Container( Container(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(

View file

@ -168,6 +168,7 @@ class _ChatListEntryState extends State<ChatListEntry> {
message: widget.message, message: widget.message,
borderRadius: borderRadius, borderRadius: borderRadius,
info: info, info: info,
contactsById: widget.useSharedData ? widget.userIdToContact : null,
); );
} }
@ -184,6 +185,7 @@ class _ChatListEntryState extends State<ChatListEntry> {
message: widget.message, message: widget.message,
borderRadius: borderRadius, borderRadius: borderRadius,
info: info, info: info,
contactsById: widget.useSharedData ? widget.userIdToContact : null,
); );
} }

View file

@ -20,12 +20,14 @@ class ChatAskAFriendEntry extends StatefulWidget {
required this.message, required this.message,
required this.borderRadius, required this.borderRadius,
required this.info, required this.info,
this.contactsById,
super.key, super.key,
}); });
final Message message; final Message message;
final BorderRadiusGeometry borderRadius; final BorderRadiusGeometry borderRadius;
final BubbleInfo info; final BubbleInfo info;
final Map<int, Contact>? contactsById;
@override @override
State<ChatAskAFriendEntry> createState() => _ChatAskAFriendEntryState(); State<ChatAskAFriendEntry> createState() => _ChatAskAFriendEntryState();
@ -56,6 +58,11 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
Future<void> _loadUser() async { Future<void> _loadUser() async {
if (_data == null || !_data!.hasAskAboutUserId()) return; if (_data == null || !_data!.hasAskAboutUserId()) return;
final userId = _data!.askAboutUserId.toInt(); final userId = _data!.askAboutUserId.toInt();
final sharedContact = widget.contactsById?[userId];
if (sharedContact != null) {
_username = sharedContact.displayName ?? sharedContact.username;
return;
}
setState(() { setState(() {
_isLoading = true; _isLoading = true;
}); });
@ -162,7 +169,9 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
StreamBuilder<Contact?>( StreamBuilder<Contact?>(
stream: twonlyDB.contactsDao.watchContact(userId), stream: widget.contactsById == null
? twonlyDB.contactsDao.watchContact(userId)
: Stream.value(widget.contactsById![userId]),
builder: (context, snapshot) { builder: (context, snapshot) {
final contactInDb = snapshot.data; final contactInDb = snapshot.data;
return GestureDetector( return GestureDetector(
@ -181,6 +190,9 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
children: [ children: [
AvatarIcon( AvatarIcon(
contactId: userId, contactId: userId,
contacts: widget.contactsById?[userId] == null
? null
: [widget.contactsById![userId]!],
fontSize: 12, fontSize: 12,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -255,7 +267,9 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
), ),
] else ...[ ] else ...[
StreamBuilder<Contact?>( StreamBuilder<Contact?>(
stream: twonlyDB.contactsDao.watchContact(userId), stream: widget.contactsById == null
? twonlyDB.contactsDao.watchContact(userId)
: Stream.value(widget.contactsById![userId]),
builder: (context, contactSnapshot) { builder: (context, contactSnapshot) {
final contactInDb = contactSnapshot.data; final contactInDb = contactSnapshot.data;
if (contactInDb != null) { if (contactInDb != null) {
@ -269,8 +283,9 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
} }
return StreamBuilder<UserDiscoveryAnnouncedUser?>( return StreamBuilder<UserDiscoveryAnnouncedUser?>(
stream: stream: twonlyDB.userDiscoveryDao.watchAnnouncedUser(
twonlyDB.userDiscoveryDao.watchAnnouncedUser(userId), userId,
),
builder: (context, userSnapshot) { builder: (context, userSnapshot) {
final announcedUser = userSnapshot.data; final announcedUser = userSnapshot.data;
if (announcedUser != null && announcedUser.isHidden) { if (announcedUser != null && announcedUser.isHidden) {

View file

@ -10,7 +10,7 @@ import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/c
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/friendly_message_time.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/friendly_message_time.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart';
class ChatAudioEntry extends StatelessWidget { class ChatAudioEntry extends StatefulWidget {
const ChatAudioEntry({ const ChatAudioEntry({
required this.message, required this.message,
required this.mediaService, required this.mediaService,
@ -24,10 +24,50 @@ class ChatAudioEntry extends StatelessWidget {
final BorderRadius borderRadius; final BorderRadius borderRadius;
final BubbleInfo info; final BubbleInfo info;
@override
State<ChatAudioEntry> createState() => _ChatAudioEntryState();
}
class _ChatAudioEntryState extends State<ChatAudioEntry> {
bool? _hasAudioFile;
bool _hasTempFile = false;
Message get message => widget.message;
MediaFileService get mediaService => widget.mediaService;
BorderRadius get borderRadius => widget.borderRadius;
BubbleInfo get info => widget.info;
@override
void initState() {
super.initState();
_checkAudioFiles();
}
@override
void didUpdateWidget(ChatAudioEntry oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.mediaService.mediaFile != oldWidget.mediaService.mediaFile) {
_checkAudioFiles();
}
}
Future<void> _checkAudioFiles() async {
// Async checks keep audio discovery off the UI thread.
// ignore: avoid_slow_async_io
final hasTempFile = await mediaService.tempPath.exists();
// ignore: avoid_slow_async_io
final hasOriginalFile = await mediaService.originalPath.exists();
final hasAudioFile = hasTempFile || hasOriginalFile;
if (!mounted) return;
setState(() {
_hasTempFile = hasTempFile;
_hasAudioFile = hasAudioFile;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!mediaService.tempPath.existsSync() && if (_hasAudioFile != true) {
!mediaService.originalPath.existsSync()) {
return Container(); // media file was purged return Container(); // media file was purged
} }
@ -73,16 +113,20 @@ class ChatAudioEntry extends StatelessWidget {
) )
else else
Expanded( Expanded(
child: mediaService.mediaFile.downloadState == child:
mediaService.mediaFile.downloadState ==
DownloadState.ready || DownloadState.ready ||
mediaService.mediaFile.downloadState == null mediaService.mediaFile.downloadState == null
? (mediaService.tempPath.existsSync() ? (_hasTempFile
? InChatAudioPlayer( ? InChatAudioPlayer(
path: mediaService.tempPath.path, path: mediaService.tempPath.path,
message: message, message: message,
) )
: Container()) : Container())
: MessageSendStateIcon([message], [mediaService.mediaFile]), : MessageSendStateIcon(
[message],
[mediaService.mediaFile],
),
), ),
if (showTime) FriendlyMessageTime(message: message), if (showTime) FriendlyMessageTime(message: message),
], ],

View file

@ -20,12 +20,14 @@ class ChatContactsEntry extends StatefulWidget {
required this.message, required this.message,
required this.borderRadius, required this.borderRadius,
required this.info, required this.info,
this.contactsById,
super.key, super.key,
}); });
final Message message; final Message message;
final BorderRadiusGeometry borderRadius; final BorderRadiusGeometry borderRadius;
final BubbleInfo info; final BubbleInfo info;
final Map<int, Contact>? contactsById;
@override @override
State<ChatContactsEntry> createState() => _ChatContactsEntryState(); State<ChatContactsEntry> createState() => _ChatContactsEntryState();
@ -73,6 +75,7 @@ class _ChatContactsEntryState extends State<ChatContactsEntry> {
_ContactRow( _ContactRow(
contact: data.contacts[i], contact: data.contacts[i],
message: widget.message, message: widget.message,
contactsById: widget.contactsById,
), ),
], ],
], ],
@ -86,10 +89,12 @@ class _ContactRow extends StatefulWidget {
const _ContactRow({ const _ContactRow({
required this.contact, required this.contact,
required this.message, required this.message,
this.contactsById,
}); });
final SharedContact contact; final SharedContact contact;
final Message message; final Message message;
final Map<int, Contact>? contactsById;
@override @override
State<_ContactRow> createState() => _ContactRowState(); State<_ContactRow> createState() => _ContactRowState();
@ -161,14 +166,23 @@ class _ContactRowState extends State<_ContactRow> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.contactsById != null) {
return _buildContactRow(
widget.contactsById![widget.contact.userId.toInt()],
);
}
return StreamBuilder<Contact?>( return StreamBuilder<Contact?>(
stream: twonlyDB.contactsDao.watchContact(widget.contact.userId.toInt()), stream: twonlyDB.contactsDao.watchContact(widget.contact.userId.toInt()),
builder: (context, snapshot) { builder: (context, snapshot) {
final contactInDb = snapshot.data; return _buildContactRow(snapshot.data);
},
);
}
Widget _buildContactRow(Contact? contactInDb) {
final isAdded = final isAdded =
contactInDb != null || contactInDb != null ||
widget.contact.userId.toInt() == userService.currentUser.userId; widget.contact.userId.toInt() == userService.currentUser.userId;
return GestureDetector( return GestureDetector(
onTap: _isLoading ? null : () => _onContactClick(isAdded), onTap: _isLoading ? null : () => _onContactClick(isAdded),
child: ColoredBox( child: ColoredBox(
@ -215,7 +229,5 @@ class _ContactRowState extends State<_ContactRow> {
), ),
), ),
); );
},
);
} }
} }

View file

@ -63,7 +63,9 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
widget.mediaService.mediaFile.displayLimitInMilliseconds != null) { widget.mediaService.mediaFile.displayLimitInMilliseconds != null) {
return; return;
} }
if (widget.mediaService.tempPath.existsSync() && mounted) { // Async check keeps media discovery off the UI thread.
// ignore: avoid_slow_async_io
if (await widget.mediaService.tempPath.exists() && mounted) {
setState(() { setState(() {
_canBeReopened = true; _canBeReopened = true;
}); });

View file

@ -234,7 +234,6 @@ class _ResponsePreviewState extends State<ResponsePreview> {
final pathToCheck = isVideo final pathToCheck = isVideo
? _mediaService!.thumbnailPath ? _mediaService!.thumbnailPath
: _mediaService!.storedPath; : _mediaService!.storedPath;
if (pathToCheck.existsSync() && pathToCheck.lengthSync() > 0) {
imageWidget = Container( imageWidget = Container(
height: 40, height: 40,
width: 40, width: 40,
@ -244,11 +243,11 @@ class _ResponsePreviewState extends State<ResponsePreview> {
child: Image.file( child: Image.file(
pathToCheck, pathToCheck,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
), ),
), ),
); );
} }
}
return Container( return Container(
padding: EdgeInsets.only( padding: EdgeInsets.only(

View file

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
@ -35,6 +36,7 @@ class DeveloperSettingsView extends StatefulWidget {
class _DeveloperSettingsViewState extends State<DeveloperSettingsView> { class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
bool _isGeneratingMockImages = false; bool _isGeneratingMockImages = false;
bool _isGeneratingMockMessages = false;
@override @override
void initState() { void initState() {
@ -254,6 +256,105 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
} }
} }
Future<void> _generate1000RandomMessages() async {
if (!kDebugMode || _isGeneratingMockMessages) return;
final groups = await twonlyDB.groupsDao.getAllGroups();
groups.sort((a, b) => a.groupName.compareTo(b.groupName));
if (!mounted) return;
if (groups.isEmpty) {
showSnackbar(context, 'No groups available for message generation.');
return;
}
final group = await showDialog<Group>(
context: context,
builder: (context) => SimpleDialog(
title: const Text('Generate messages in group'),
children: [
for (final group in groups)
SimpleDialogOption(
onPressed: () => Navigator.pop(context, group),
child: Text(group.groupName),
),
],
),
);
if (group == null || !mounted) return;
setState(() => _isGeneratingMockMessages = true);
try {
final members = await twonlyDB.groupsDao.getGroupNonLeftMembers(
group.groupId,
);
if (members.isEmpty) {
if (mounted) {
showSnackbar(context, 'The selected group has no active members.');
}
return;
}
const samples = [
'Hey! How is everyone doing?',
'This is a randomly generated debug message.',
'Did anyone see this?',
'Sounds good to me!',
'I will check that later.',
'That made me laugh 😄',
'What do you think?',
'Lets do it!',
'Quick update from my side.',
'Testing a longer message to see how the chat bubble wraps across multiple lines in this conversation.',
];
final random = Random();
final now = clock.now();
final idPrefix = 'debug_${now.microsecondsSinceEpoch}';
await twonlyDB.batch((batch) {
for (var i = 0; i < 1000; i++) {
final member = members[random.nextInt(members.length)];
final createdAt = now.subtract(Duration(seconds: 1000 - i));
final sample = samples[random.nextInt(samples.length)];
batch.insert(
twonlyDB.messages,
MessagesCompanion(
groupId: Value(group.groupId),
messageId: Value('${idPrefix}_$i'),
senderId: Value(member.contactId),
type: const Value('text'),
content: Value('$sample #${i + 1}'),
openedAt: Value(now),
ackByServer: Value(createdAt),
ackByUser: Value(createdAt),
createdAt: Value(createdAt),
),
);
}
});
await twonlyDB.groupsDao.updateGroup(
group.groupId,
GroupsCompanion(
archived: const Value(false),
deletedContent: const Value(false),
lastMessageReceived: Value(now),
lastMessageExchange: Value(now),
),
);
if (mounted) {
showSnackbar(
context,
'Generated 1000 messages in ${group.groupName}.',
level: SnackbarLevel.success,
);
}
} catch (error) {
if (mounted) showSnackbar(context, 'Could not generate messages: $error');
} finally {
if (mounted) setState(() => _isGeneratingMockMessages = false);
}
}
Future<void> toggleDeveloperSettings() async { Future<void> toggleDeveloperSettings() async {
await UserService.update((u) => u.isDeveloper = !u.isDeveloper); await UserService.update((u) => u.isDeveloper = !u.isDeveloper);
} }
@ -376,6 +477,23 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
onTap: () => onTap: () =>
context.push(Routes.settingsDeveloperAutomatedTesting), context.push(Routes.settingsDeveloperAutomatedTesting),
), ),
if (kDebugMode)
ListTile(
title: const Text('Generate 1000 Random Messages'),
subtitle: const Text('Choose a group to populate'),
trailing: _isGeneratingMockMessages
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
)
: null,
onTap: _isGeneratingMockMessages
? null
: _generate1000RandomMessages,
),
if (kDebugMode) if (kDebugMode)
ListTile( ListTile(
title: const Text('Generate 1000 Mock Images'), title: const Text('Generate 1000 Mock Images'),