mirror of
https://gitlab.com/veilid/veilidchat.git
synced 2025-09-20 12:34:49 -04:00
new chat widget
This commit is contained in:
parent
063eeb8d12
commit
1a9cca0667
44 changed files with 1904 additions and 981 deletions
|
@ -4,11 +4,8 @@ import 'dart:typed_data';
|
|||
import 'package:async_tools/async_tools.dart';
|
||||
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
||||
import 'package:scroll_to_index/scroll_to_index.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart' as core;
|
||||
import 'package:veilid_support/veilid_support.dart';
|
||||
|
||||
import '../../account_manager/account_manager.dart';
|
||||
|
@ -19,6 +16,7 @@ import '../../tools/tools.dart';
|
|||
import '../models/chat_component_state.dart';
|
||||
import '../models/message_state.dart';
|
||||
import '../models/window_state.dart';
|
||||
import '../views/chat_component_widget.dart';
|
||||
import 'cubits.dart';
|
||||
|
||||
const metadataKeyIdentityPublicKey = 'identityPublicKey';
|
||||
|
@ -39,15 +37,12 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
|
|||
_contactListCubit = contactListCubit,
|
||||
_conversationCubits = conversationCubits,
|
||||
_messagesCubit = messagesCubit,
|
||||
super(ChatComponentState(
|
||||
chatKey: GlobalKey<ChatState>(),
|
||||
scrollController: AutoScrollController(),
|
||||
textEditingController: InputTextFieldController(),
|
||||
super(const ChatComponentState(
|
||||
localUser: null,
|
||||
remoteUsers: const IMap.empty(),
|
||||
historicalRemoteUsers: const IMap.empty(),
|
||||
unknownUsers: const IMap.empty(),
|
||||
messageWindow: const AsyncLoading(),
|
||||
remoteUsers: IMap.empty(),
|
||||
historicalRemoteUsers: IMap.empty(),
|
||||
unknownUsers: IMap.empty(),
|
||||
messageWindow: AsyncLoading(),
|
||||
title: '',
|
||||
)) {
|
||||
// Immediate Init
|
||||
|
@ -102,6 +97,7 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
|
|||
await _accountRecordSubscription.cancel();
|
||||
await _messagesSubscription.cancel();
|
||||
await _conversationSubscriptions.values.map((v) => v.cancel()).wait;
|
||||
|
||||
await super.close();
|
||||
}
|
||||
|
||||
|
@ -122,32 +118,15 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
|
|||
}
|
||||
|
||||
// Send a message
|
||||
void sendMessage(types.PartialText message) {
|
||||
final text = message.text;
|
||||
|
||||
final replyId = (message.repliedMessage != null)
|
||||
? base64UrlNoPadDecode(message.repliedMessage!.id)
|
||||
void sendMessage(
|
||||
{required String text,
|
||||
String? replyToMessageId,
|
||||
Timestamp? expiration,
|
||||
int? viewLimit,
|
||||
List<proto.Attachment>? attachments}) {
|
||||
final replyId = (replyToMessageId != null)
|
||||
? base64UrlNoPadDecode(replyToMessageId)
|
||||
: null;
|
||||
Timestamp? expiration;
|
||||
int? viewLimit;
|
||||
List<proto.Attachment>? attachments;
|
||||
final metadata = message.metadata;
|
||||
if (metadata != null) {
|
||||
final expirationValue =
|
||||
metadata[metadataKeyExpirationDuration] as TimestampDuration?;
|
||||
if (expirationValue != null) {
|
||||
expiration = Veilid.instance.now().offset(expirationValue);
|
||||
}
|
||||
final viewLimitValue = metadata[metadataKeyViewLimit] as int?;
|
||||
if (viewLimitValue != null) {
|
||||
viewLimit = viewLimitValue;
|
||||
}
|
||||
final attachmentsValue =
|
||||
metadata[metadataKeyAttachments] as List<proto.Attachment>?;
|
||||
if (attachmentsValue != null) {
|
||||
attachments = attachmentsValue;
|
||||
}
|
||||
}
|
||||
|
||||
_addTextMessage(
|
||||
text: text,
|
||||
|
@ -172,9 +151,9 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
|
|||
emit(state.copyWith(localUser: null));
|
||||
return;
|
||||
}
|
||||
final localUser = types.User(
|
||||
final localUser = core.User(
|
||||
id: _localUserIdentityKey.toString(),
|
||||
firstName: account.profile.name,
|
||||
name: account.profile.name,
|
||||
metadata: {metadataKeyIdentityPublicKey: _localUserIdentityKey});
|
||||
emit(state.copyWith(localUser: localUser));
|
||||
}
|
||||
|
@ -199,11 +178,12 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
|
|||
// Don't change user information on loading state
|
||||
return;
|
||||
}
|
||||
|
||||
final remoteUser =
|
||||
_convertRemoteUser(remoteIdentityPublicKey, activeConversationState);
|
||||
|
||||
emit(_updateTitle(state.copyWith(
|
||||
remoteUsers: state.remoteUsers.add(
|
||||
remoteIdentityPublicKey,
|
||||
_convertRemoteUser(
|
||||
remoteIdentityPublicKey, activeConversationState)))));
|
||||
remoteUsers: state.remoteUsers.add(remoteUser.id, remoteUser))));
|
||||
}
|
||||
|
||||
static ChatComponentState _updateTitle(ChatComponentState currentState) {
|
||||
|
@ -212,13 +192,13 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
|
|||
}
|
||||
if (currentState.remoteUsers.length == 1) {
|
||||
final remoteUser = currentState.remoteUsers.values.first;
|
||||
return currentState.copyWith(title: remoteUser.firstName ?? '<unnamed>');
|
||||
return currentState.copyWith(title: remoteUser.name ?? '<unnamed>');
|
||||
}
|
||||
return currentState.copyWith(
|
||||
title: '<group chat with ${currentState.remoteUsers.length} users>');
|
||||
}
|
||||
|
||||
types.User _convertRemoteUser(TypedKey remoteIdentityPublicKey,
|
||||
core.User _convertRemoteUser(TypedKey remoteIdentityPublicKey,
|
||||
ActiveConversationState activeConversationState) {
|
||||
// See if we have a contact for this remote user
|
||||
final contacts = _contactListCubit.state.state.asData?.value;
|
||||
|
@ -227,25 +207,24 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
|
|||
x.value.identityPublicKey.toVeilid() == remoteIdentityPublicKey);
|
||||
if (contactIdx != -1) {
|
||||
final contact = contacts[contactIdx].value;
|
||||
return types.User(
|
||||
return core.User(
|
||||
id: remoteIdentityPublicKey.toString(),
|
||||
firstName: contact.displayName,
|
||||
name: contact.displayName,
|
||||
metadata: {metadataKeyIdentityPublicKey: remoteIdentityPublicKey});
|
||||
}
|
||||
}
|
||||
|
||||
return types.User(
|
||||
return core.User(
|
||||
id: remoteIdentityPublicKey.toString(),
|
||||
firstName: activeConversationState.remoteConversation?.profile.name ??
|
||||
name: activeConversationState.remoteConversation?.profile.name ??
|
||||
'<unnamed>',
|
||||
metadata: {metadataKeyIdentityPublicKey: remoteIdentityPublicKey});
|
||||
}
|
||||
|
||||
types.User _convertUnknownUser(TypedKey remoteIdentityPublicKey) =>
|
||||
types.User(
|
||||
id: remoteIdentityPublicKey.toString(),
|
||||
firstName: '<$remoteIdentityPublicKey>',
|
||||
metadata: {metadataKeyIdentityPublicKey: remoteIdentityPublicKey});
|
||||
core.User _convertUnknownUser(TypedKey remoteIdentityPublicKey) => core.User(
|
||||
id: remoteIdentityPublicKey.toString(),
|
||||
name: '<$remoteIdentityPublicKey>',
|
||||
metadata: {metadataKeyIdentityPublicKey: remoteIdentityPublicKey});
|
||||
|
||||
Future<void> _updateConversationSubscriptions() async {
|
||||
// Get existing subscription keys and state
|
||||
|
@ -267,16 +246,17 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
|
|||
|
||||
final activeConversationState = cc.state.asData?.value;
|
||||
if (activeConversationState != null) {
|
||||
currentRemoteUsersState = currentRemoteUsersState.add(
|
||||
remoteIdentityPublicKey,
|
||||
_convertRemoteUser(
|
||||
remoteIdentityPublicKey, activeConversationState));
|
||||
final remoteUser = _convertRemoteUser(
|
||||
remoteIdentityPublicKey, activeConversationState);
|
||||
currentRemoteUsersState =
|
||||
currentRemoteUsersState.add(remoteUser.id, remoteUser);
|
||||
}
|
||||
}
|
||||
// Purge remote users we didn't see in the cubit list any more
|
||||
final cancels = <Future<void>>[];
|
||||
for (final deadUser in existing) {
|
||||
currentRemoteUsersState = currentRemoteUsersState.remove(deadUser);
|
||||
currentRemoteUsersState =
|
||||
currentRemoteUsersState.remove(deadUser.toString());
|
||||
cancels.add(_conversationSubscriptions.remove(deadUser)!.cancel());
|
||||
}
|
||||
await cancels.wait;
|
||||
|
@ -285,63 +265,76 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
|
|||
emit(_updateTitle(state.copyWith(remoteUsers: currentRemoteUsersState)));
|
||||
}
|
||||
|
||||
(ChatComponentState, types.Message?) _messageStateToChatMessage(
|
||||
(ChatComponentState, core.Message?) _messageStateToChatMessage(
|
||||
ChatComponentState currentState, MessageState message) {
|
||||
final authorIdentityPublicKey = message.content.author.toVeilid();
|
||||
late final types.User author;
|
||||
final authorUserId = authorIdentityPublicKey.toString();
|
||||
|
||||
late final core.User author;
|
||||
if (authorIdentityPublicKey == _localUserIdentityKey &&
|
||||
currentState.localUser != null) {
|
||||
author = currentState.localUser!;
|
||||
} else {
|
||||
final remoteUser = currentState.remoteUsers[authorIdentityPublicKey];
|
||||
final remoteUser = currentState.remoteUsers[authorUserId];
|
||||
if (remoteUser != null) {
|
||||
author = remoteUser;
|
||||
} else {
|
||||
final historicalRemoteUser =
|
||||
currentState.historicalRemoteUsers[authorIdentityPublicKey];
|
||||
currentState.historicalRemoteUsers[authorUserId];
|
||||
if (historicalRemoteUser != null) {
|
||||
author = historicalRemoteUser;
|
||||
} else {
|
||||
final unknownRemoteUser =
|
||||
currentState.unknownUsers[authorIdentityPublicKey];
|
||||
final unknownRemoteUser = currentState.unknownUsers[authorUserId];
|
||||
if (unknownRemoteUser != null) {
|
||||
author = unknownRemoteUser;
|
||||
} else {
|
||||
final unknownUser = _convertUnknownUser(authorIdentityPublicKey);
|
||||
currentState = currentState.copyWith(
|
||||
unknownUsers: currentState.unknownUsers
|
||||
.add(authorIdentityPublicKey, unknownUser));
|
||||
unknownUsers:
|
||||
currentState.unknownUsers.add(authorUserId, unknownUser));
|
||||
author = unknownUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
types.Status? status;
|
||||
if (message.sendState != null) {
|
||||
assert(author.id == _localUserIdentityKey.toString(),
|
||||
'send state should only be on sent messages');
|
||||
switch (message.sendState!) {
|
||||
case MessageSendState.sending:
|
||||
status = types.Status.sending;
|
||||
case MessageSendState.sent:
|
||||
status = types.Status.sent;
|
||||
case MessageSendState.delivered:
|
||||
status = types.Status.delivered;
|
||||
}
|
||||
}
|
||||
// types.Status? status;
|
||||
// if (message.sendState != null) {
|
||||
// assert(author.id == _localUserIdentityKey.toString(),
|
||||
// 'send state should only be on sent messages');
|
||||
// switch (message.sendState!) {
|
||||
// case MessageSendState.sending:
|
||||
// status = types.Status.sending;
|
||||
// case MessageSendState.sent:
|
||||
// status = types.Status.sent;
|
||||
// case MessageSendState.delivered:
|
||||
// status = types.Status.delivered;
|
||||
// }
|
||||
// }
|
||||
|
||||
final reconciledAt = message.reconciledTimestamp == null
|
||||
? null
|
||||
: DateTime.fromMicrosecondsSinceEpoch(
|
||||
message.reconciledTimestamp!.value.toInt());
|
||||
|
||||
// print('message seqid: ${message.seqId}');
|
||||
|
||||
switch (message.content.whichKind()) {
|
||||
case proto.Message_Kind.text:
|
||||
final contextText = message.content.text;
|
||||
final textMessage = types.TextMessage(
|
||||
author: author,
|
||||
createdAt:
|
||||
(message.sentTimestamp.value ~/ BigInt.from(1000)).toInt(),
|
||||
id: message.content.authorUniqueIdString,
|
||||
text: contextText.text,
|
||||
showStatus: status != null,
|
||||
status: status);
|
||||
final reconciledId = message.content.authorUniqueIdString;
|
||||
final contentText = message.content.text;
|
||||
final textMessage = core.TextMessage(
|
||||
authorId: author.id,
|
||||
createdAt: DateTime.fromMicrosecondsSinceEpoch(
|
||||
message.sentTimestamp.value.toInt()),
|
||||
sentAt: reconciledAt,
|
||||
id: reconciledId,
|
||||
text: '${contentText.text} (${message.seqId})',
|
||||
//text: contentText.text,
|
||||
metadata: {
|
||||
kSeqId: message.seqId,
|
||||
if (core.isOnlyEmoji(contentText.text)) 'isOnlyEmoji': true,
|
||||
});
|
||||
return (currentState, textMessage);
|
||||
case proto.Message_Kind.secret:
|
||||
case proto.Message_Kind.delete:
|
||||
|
@ -375,7 +368,7 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
|
|||
final messagesState = avMessagesState.asData!.value;
|
||||
|
||||
// Convert protobuf messages to chat messages
|
||||
final chatMessages = <types.Message>[];
|
||||
final chatMessages = <core.Message>[];
|
||||
final tsSet = <String>{};
|
||||
for (final message in messagesState.window) {
|
||||
final (newState, chatMessage) =
|
||||
|
@ -390,11 +383,11 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
|
|||
// '\nChatMessages:\n$chatMessages'
|
||||
);
|
||||
} else {
|
||||
chatMessages.insert(0, chatMessage);
|
||||
chatMessages.add(chatMessage);
|
||||
}
|
||||
}
|
||||
return currentState.copyWith(
|
||||
messageWindow: AsyncValue.data(WindowState<types.Message>(
|
||||
messageWindow: AsyncValue.data(WindowState<core.Message>(
|
||||
window: chatMessages.toIList(),
|
||||
length: messagesState.length,
|
||||
windowTail: messagesState.windowTail,
|
||||
|
|
|
@ -3,6 +3,8 @@ import 'dart:async';
|
|||
import 'package:async_tools/async_tools.dart';
|
||||
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:uuid/v4.dart';
|
||||
import 'package:veilid_support/veilid_support.dart';
|
||||
|
||||
import '../../account_manager/account_manager.dart';
|
||||
|
@ -11,9 +13,12 @@ import '../../tools/tools.dart';
|
|||
import '../models/models.dart';
|
||||
import 'reconciliation/reconciliation.dart';
|
||||
|
||||
const _sfSendMessageTag = 'sfSendMessageTag';
|
||||
|
||||
class RenderStateElement {
|
||||
RenderStateElement(
|
||||
{required this.message,
|
||||
{required this.seqId,
|
||||
required this.message,
|
||||
required this.isLocal,
|
||||
this.reconciledTimestamp,
|
||||
this.sent = false,
|
||||
|
@ -36,6 +41,7 @@ class RenderStateElement {
|
|||
return null;
|
||||
}
|
||||
|
||||
int seqId;
|
||||
proto.Message message;
|
||||
bool isLocal;
|
||||
Timestamp? reconciledTimestamp;
|
||||
|
@ -71,6 +77,8 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
|
|||
Future<void> close() async {
|
||||
await _initWait();
|
||||
|
||||
await serialFutureClose((this, _sfSendMessageTag));
|
||||
|
||||
await _commandController.close();
|
||||
await _commandRunnerFut;
|
||||
await _unsentMessagesQueue.close();
|
||||
|
@ -309,9 +317,6 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
|
|||
|
||||
// Async process to send messages in the background
|
||||
Future<void> _processUnsentMessages(IList<proto.Message> messages) async {
|
||||
// _sendingMessages = messages;
|
||||
|
||||
// _renderState();
|
||||
try {
|
||||
await _sentMessagesDHTLog!.operateAppendEventual((writer) async {
|
||||
// Get the previous message if we have one
|
||||
|
@ -337,8 +342,6 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
|
|||
} on Exception catch (e, st) {
|
||||
log.error('Exception appending unsent messages: $e:\n$st\n');
|
||||
}
|
||||
|
||||
// _sendingMessages = const IList.empty();
|
||||
}
|
||||
|
||||
// Produce a state for this cubit from the input cubits and queues
|
||||
|
@ -349,8 +352,9 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
|
|||
|
||||
// Get all sent messages that are still offline
|
||||
//final sentMessages = _sentMessagesDHTLog.
|
||||
//Get all items in the unsent queue
|
||||
//final unsentMessages = _unsentMessagesQueue.queue;
|
||||
|
||||
// Get all items in the unsent queue
|
||||
final unsentMessages = _unsentMessagesQueue.queue;
|
||||
|
||||
// If we aren't ready to render a state, say we're loading
|
||||
if (reconciledMessages == null) {
|
||||
|
@ -374,8 +378,19 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
|
|||
// values: unsentMessages,
|
||||
// );
|
||||
|
||||
// List of all rendered state elements that we will turn into
|
||||
// message states
|
||||
final renderedElements = <RenderStateElement>[];
|
||||
|
||||
// Keep track of the ids we have rendered
|
||||
// because there can be an overlap between the 'unsent messages'
|
||||
// and the reconciled messages as the async state catches up
|
||||
final renderedIds = <String>{};
|
||||
|
||||
var seqId = (reconciledMessages.windowTail == 0
|
||||
? reconciledMessages.length
|
||||
: reconciledMessages.windowTail) -
|
||||
reconciledMessages.windowElements.length;
|
||||
for (final m in reconciledMessages.windowElements) {
|
||||
final isLocal =
|
||||
m.content.author.toVeilid() == _accountInfo.identityTypedPublicKey;
|
||||
|
@ -387,33 +402,44 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
|
|||
final sent = isLocal;
|
||||
final sentOffline = false; //
|
||||
|
||||
if (renderedIds.contains(m.content.authorUniqueIdString)) {
|
||||
seqId++;
|
||||
continue;
|
||||
}
|
||||
renderedElements.add(RenderStateElement(
|
||||
seqId: seqId,
|
||||
message: m.content,
|
||||
isLocal: isLocal,
|
||||
reconciledTimestamp: reconciledTimestamp,
|
||||
sent: sent,
|
||||
sentOffline: sentOffline,
|
||||
));
|
||||
|
||||
renderedIds.add(m.content.authorUniqueIdString);
|
||||
seqId++;
|
||||
}
|
||||
|
||||
// Render in-flight messages at the bottom
|
||||
// for (final m in _sendingMessages) {
|
||||
//
|
||||
// for (final m in unsentMessages) {
|
||||
// if (renderedIds.contains(m.authorUniqueIdString)) {
|
||||
// seqId++;
|
||||
// continue;
|
||||
// }
|
||||
// renderedElements.add(RenderStateElement(
|
||||
// seqId: seqId,
|
||||
// message: m,
|
||||
// isLocal: true,
|
||||
// sent: true,
|
||||
// sentOffline: true,
|
||||
// ));
|
||||
// renderedIds.add(m.authorUniqueIdString);
|
||||
// seqId++;
|
||||
// }
|
||||
|
||||
// Render the state
|
||||
final messages = renderedElements
|
||||
.map((x) => MessageState(
|
||||
seqId: x.seqId,
|
||||
content: x.message,
|
||||
sentTimestamp: Timestamp.fromInt64(x.message.timestamp),
|
||||
reconciledTimestamp: x.reconciledTimestamp,
|
||||
|
@ -431,20 +457,26 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
|
|||
|
||||
void _sendMessage({required proto.Message message}) {
|
||||
// Add common fields
|
||||
// id and signature will get set by _processMessageToSend
|
||||
// real id and signature will get set by _processMessageToSend
|
||||
// temporary id set here is random and not 'valid' in the eyes
|
||||
// of reconcilation, noting that reconciled timestamp is not yet set.
|
||||
message
|
||||
..author = _accountInfo.identityTypedPublicKey.toProto()
|
||||
..timestamp = Veilid.instance.now().toInt64();
|
||||
..timestamp = Veilid.instance.now().toInt64()
|
||||
..id = Uuid.parse(_uuidGen.generate());
|
||||
|
||||
if ((message.writeToBuffer().lengthInBytes + 256) > 4096) {
|
||||
throw const FormatException('message is too long');
|
||||
}
|
||||
|
||||
// Put in the queue
|
||||
_unsentMessagesQueue.addSync(message);
|
||||
serialFuture((this, _sfSendMessageTag), () async {
|
||||
// Add the message to the persistent queue
|
||||
await _unsentMessagesQueue.add(message);
|
||||
|
||||
// Update the view
|
||||
_renderState();
|
||||
// Update the view
|
||||
_renderState();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _commandRunner() async {
|
||||
|
@ -487,7 +519,6 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
|
|||
late final MessageReconciliation _reconciliation;
|
||||
|
||||
late final PersistentQueue<proto.Message> _unsentMessagesQueue;
|
||||
// IList<proto.Message> _sendingMessages = const IList.empty();
|
||||
StreamSubscription<void>? _sentSubscription;
|
||||
StreamSubscription<void>? _rcvdSubscription;
|
||||
StreamSubscription<TableDBArrayProtobufBusyState<proto.ReconciledMessage>>?
|
||||
|
@ -496,4 +527,5 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
|
|||
late final Future<void> _commandRunnerFut;
|
||||
|
||||
final _sspRemoteConversationRecordKey = SingleStateProcessor<TypedKey?>();
|
||||
final _uuidGen = const UuidV4();
|
||||
}
|
||||
|
|
|
@ -1,12 +1,7 @@
|
|||
import 'package:async_tools/async_tools.dart';
|
||||
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart' show Message, User;
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart'
|
||||
show ChatState, InputTextFieldController;
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:scroll_to_index/scroll_to_index.dart';
|
||||
import 'package:veilid_support/veilid_support.dart';
|
||||
|
||||
import 'window_state.dart';
|
||||
|
||||
|
@ -16,26 +11,16 @@ part 'chat_component_state.freezed.dart';
|
|||
sealed class ChatComponentState with _$ChatComponentState {
|
||||
const factory ChatComponentState(
|
||||
{
|
||||
// GlobalKey for the chat
|
||||
required GlobalKey<ChatState> chatKey,
|
||||
// ScrollController for the chat
|
||||
required AutoScrollController scrollController,
|
||||
// TextEditingController for the chat
|
||||
required InputTextFieldController textEditingController,
|
||||
// Local user
|
||||
required User? localUser,
|
||||
// Active remote users
|
||||
required IMap<TypedKey, User> remoteUsers,
|
||||
required IMap<UserID, User> remoteUsers,
|
||||
// Historical remote users
|
||||
required IMap<TypedKey, User> historicalRemoteUsers,
|
||||
required IMap<UserID, User> historicalRemoteUsers,
|
||||
// Unknown users
|
||||
required IMap<TypedKey, User> unknownUsers,
|
||||
required IMap<UserID, User> unknownUsers,
|
||||
// Messages state
|
||||
required AsyncValue<WindowState<Message>> messageWindow,
|
||||
// Title of the chat
|
||||
required String title}) = _ChatComponentState;
|
||||
}
|
||||
|
||||
extension ChatComponentStateExt on ChatComponentState {
|
||||
//
|
||||
}
|
||||
|
|
|
@ -15,15 +15,11 @@ T _$identity<T>(T value) => value;
|
|||
|
||||
/// @nodoc
|
||||
mixin _$ChatComponentState {
|
||||
// GlobalKey for the chat
|
||||
GlobalKey<ChatState> get chatKey; // ScrollController for the chat
|
||||
AutoScrollController
|
||||
get scrollController; // TextEditingController for the chat
|
||||
InputTextFieldController get textEditingController; // Local user
|
||||
// Local user
|
||||
User? get localUser; // Active remote users
|
||||
IMap<TypedKey, User> get remoteUsers; // Historical remote users
|
||||
IMap<TypedKey, User> get historicalRemoteUsers; // Unknown users
|
||||
IMap<TypedKey, User> get unknownUsers; // Messages state
|
||||
IMap<UserID, User> get remoteUsers; // Historical remote users
|
||||
IMap<UserID, User> get historicalRemoteUsers; // Unknown users
|
||||
IMap<UserID, User> get unknownUsers; // Messages state
|
||||
AsyncValue<WindowState<Message>> get messageWindow; // Title of the chat
|
||||
String get title;
|
||||
|
||||
|
@ -40,11 +36,6 @@ mixin _$ChatComponentState {
|
|||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is ChatComponentState &&
|
||||
(identical(other.chatKey, chatKey) || other.chatKey == chatKey) &&
|
||||
(identical(other.scrollController, scrollController) ||
|
||||
other.scrollController == scrollController) &&
|
||||
(identical(other.textEditingController, textEditingController) ||
|
||||
other.textEditingController == textEditingController) &&
|
||||
(identical(other.localUser, localUser) ||
|
||||
other.localUser == localUser) &&
|
||||
(identical(other.remoteUsers, remoteUsers) ||
|
||||
|
@ -59,21 +50,12 @@ mixin _$ChatComponentState {
|
|||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
chatKey,
|
||||
scrollController,
|
||||
textEditingController,
|
||||
localUser,
|
||||
remoteUsers,
|
||||
historicalRemoteUsers,
|
||||
unknownUsers,
|
||||
messageWindow,
|
||||
title);
|
||||
int get hashCode => Object.hash(runtimeType, localUser, remoteUsers,
|
||||
historicalRemoteUsers, unknownUsers, messageWindow, title);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatComponentState(chatKey: $chatKey, scrollController: $scrollController, textEditingController: $textEditingController, localUser: $localUser, remoteUsers: $remoteUsers, historicalRemoteUsers: $historicalRemoteUsers, unknownUsers: $unknownUsers, messageWindow: $messageWindow, title: $title)';
|
||||
return 'ChatComponentState(localUser: $localUser, remoteUsers: $remoteUsers, historicalRemoteUsers: $historicalRemoteUsers, unknownUsers: $unknownUsers, messageWindow: $messageWindow, title: $title)';
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -84,16 +66,14 @@ abstract mixin class $ChatComponentStateCopyWith<$Res> {
|
|||
_$ChatComponentStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call(
|
||||
{GlobalKey<ChatState> chatKey,
|
||||
AutoScrollController scrollController,
|
||||
InputTextFieldController textEditingController,
|
||||
User? localUser,
|
||||
IMap<Typed<FixedEncodedString43>, User> remoteUsers,
|
||||
IMap<Typed<FixedEncodedString43>, User> historicalRemoteUsers,
|
||||
IMap<Typed<FixedEncodedString43>, User> unknownUsers,
|
||||
{User? localUser,
|
||||
IMap<String, User> remoteUsers,
|
||||
IMap<String, User> historicalRemoteUsers,
|
||||
IMap<String, User> unknownUsers,
|
||||
AsyncValue<WindowState<Message>> messageWindow,
|
||||
String title});
|
||||
|
||||
$UserCopyWith<$Res>? get localUser;
|
||||
$AsyncValueCopyWith<WindowState<Message>, $Res> get messageWindow;
|
||||
}
|
||||
|
||||
|
@ -110,9 +90,6 @@ class _$ChatComponentStateCopyWithImpl<$Res>
|
|||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? chatKey = null,
|
||||
Object? scrollController = null,
|
||||
Object? textEditingController = null,
|
||||
Object? localUser = freezed,
|
||||
Object? remoteUsers = null,
|
||||
Object? historicalRemoteUsers = null,
|
||||
|
@ -121,18 +98,6 @@ class _$ChatComponentStateCopyWithImpl<$Res>
|
|||
Object? title = null,
|
||||
}) {
|
||||
return _then(_self.copyWith(
|
||||
chatKey: null == chatKey
|
||||
? _self.chatKey
|
||||
: chatKey // ignore: cast_nullable_to_non_nullable
|
||||
as GlobalKey<ChatState>,
|
||||
scrollController: null == scrollController
|
||||
? _self.scrollController
|
||||
: scrollController // ignore: cast_nullable_to_non_nullable
|
||||
as AutoScrollController,
|
||||
textEditingController: null == textEditingController
|
||||
? _self.textEditingController
|
||||
: textEditingController // ignore: cast_nullable_to_non_nullable
|
||||
as InputTextFieldController,
|
||||
localUser: freezed == localUser
|
||||
? _self.localUser
|
||||
: localUser // ignore: cast_nullable_to_non_nullable
|
||||
|
@ -140,15 +105,15 @@ class _$ChatComponentStateCopyWithImpl<$Res>
|
|||
remoteUsers: null == remoteUsers
|
||||
? _self.remoteUsers!
|
||||
: remoteUsers // ignore: cast_nullable_to_non_nullable
|
||||
as IMap<Typed<FixedEncodedString43>, User>,
|
||||
as IMap<String, User>,
|
||||
historicalRemoteUsers: null == historicalRemoteUsers
|
||||
? _self.historicalRemoteUsers!
|
||||
: historicalRemoteUsers // ignore: cast_nullable_to_non_nullable
|
||||
as IMap<Typed<FixedEncodedString43>, User>,
|
||||
as IMap<String, User>,
|
||||
unknownUsers: null == unknownUsers
|
||||
? _self.unknownUsers!
|
||||
: unknownUsers // ignore: cast_nullable_to_non_nullable
|
||||
as IMap<Typed<FixedEncodedString43>, User>,
|
||||
as IMap<String, User>,
|
||||
messageWindow: null == messageWindow
|
||||
? _self.messageWindow
|
||||
: messageWindow // ignore: cast_nullable_to_non_nullable
|
||||
|
@ -160,6 +125,20 @@ class _$ChatComponentStateCopyWithImpl<$Res>
|
|||
));
|
||||
}
|
||||
|
||||
/// Create a copy of ChatComponentState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$UserCopyWith<$Res>? get localUser {
|
||||
if (_self.localUser == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $UserCopyWith<$Res>(_self.localUser!, (value) {
|
||||
return _then(_self.copyWith(localUser: value));
|
||||
});
|
||||
}
|
||||
|
||||
/// Create a copy of ChatComponentState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
|
@ -176,37 +155,25 @@ class _$ChatComponentStateCopyWithImpl<$Res>
|
|||
|
||||
class _ChatComponentState implements ChatComponentState {
|
||||
const _ChatComponentState(
|
||||
{required this.chatKey,
|
||||
required this.scrollController,
|
||||
required this.textEditingController,
|
||||
required this.localUser,
|
||||
{required this.localUser,
|
||||
required this.remoteUsers,
|
||||
required this.historicalRemoteUsers,
|
||||
required this.unknownUsers,
|
||||
required this.messageWindow,
|
||||
required this.title});
|
||||
|
||||
// GlobalKey for the chat
|
||||
@override
|
||||
final GlobalKey<ChatState> chatKey;
|
||||
// ScrollController for the chat
|
||||
@override
|
||||
final AutoScrollController scrollController;
|
||||
// TextEditingController for the chat
|
||||
@override
|
||||
final InputTextFieldController textEditingController;
|
||||
// Local user
|
||||
@override
|
||||
final User? localUser;
|
||||
// Active remote users
|
||||
@override
|
||||
final IMap<Typed<FixedEncodedString43>, User> remoteUsers;
|
||||
final IMap<String, User> remoteUsers;
|
||||
// Historical remote users
|
||||
@override
|
||||
final IMap<Typed<FixedEncodedString43>, User> historicalRemoteUsers;
|
||||
final IMap<String, User> historicalRemoteUsers;
|
||||
// Unknown users
|
||||
@override
|
||||
final IMap<Typed<FixedEncodedString43>, User> unknownUsers;
|
||||
final IMap<String, User> unknownUsers;
|
||||
// Messages state
|
||||
@override
|
||||
final AsyncValue<WindowState<Message>> messageWindow;
|
||||
|
@ -227,11 +194,6 @@ class _ChatComponentState implements ChatComponentState {
|
|||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _ChatComponentState &&
|
||||
(identical(other.chatKey, chatKey) || other.chatKey == chatKey) &&
|
||||
(identical(other.scrollController, scrollController) ||
|
||||
other.scrollController == scrollController) &&
|
||||
(identical(other.textEditingController, textEditingController) ||
|
||||
other.textEditingController == textEditingController) &&
|
||||
(identical(other.localUser, localUser) ||
|
||||
other.localUser == localUser) &&
|
||||
(identical(other.remoteUsers, remoteUsers) ||
|
||||
|
@ -246,21 +208,12 @@ class _ChatComponentState implements ChatComponentState {
|
|||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
chatKey,
|
||||
scrollController,
|
||||
textEditingController,
|
||||
localUser,
|
||||
remoteUsers,
|
||||
historicalRemoteUsers,
|
||||
unknownUsers,
|
||||
messageWindow,
|
||||
title);
|
||||
int get hashCode => Object.hash(runtimeType, localUser, remoteUsers,
|
||||
historicalRemoteUsers, unknownUsers, messageWindow, title);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatComponentState(chatKey: $chatKey, scrollController: $scrollController, textEditingController: $textEditingController, localUser: $localUser, remoteUsers: $remoteUsers, historicalRemoteUsers: $historicalRemoteUsers, unknownUsers: $unknownUsers, messageWindow: $messageWindow, title: $title)';
|
||||
return 'ChatComponentState(localUser: $localUser, remoteUsers: $remoteUsers, historicalRemoteUsers: $historicalRemoteUsers, unknownUsers: $unknownUsers, messageWindow: $messageWindow, title: $title)';
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -273,16 +226,15 @@ abstract mixin class _$ChatComponentStateCopyWith<$Res>
|
|||
@override
|
||||
@useResult
|
||||
$Res call(
|
||||
{GlobalKey<ChatState> chatKey,
|
||||
AutoScrollController scrollController,
|
||||
InputTextFieldController textEditingController,
|
||||
User? localUser,
|
||||
IMap<Typed<FixedEncodedString43>, User> remoteUsers,
|
||||
IMap<Typed<FixedEncodedString43>, User> historicalRemoteUsers,
|
||||
IMap<Typed<FixedEncodedString43>, User> unknownUsers,
|
||||
{User? localUser,
|
||||
IMap<String, User> remoteUsers,
|
||||
IMap<String, User> historicalRemoteUsers,
|
||||
IMap<String, User> unknownUsers,
|
||||
AsyncValue<WindowState<Message>> messageWindow,
|
||||
String title});
|
||||
|
||||
@override
|
||||
$UserCopyWith<$Res>? get localUser;
|
||||
@override
|
||||
$AsyncValueCopyWith<WindowState<Message>, $Res> get messageWindow;
|
||||
}
|
||||
|
@ -300,9 +252,6 @@ class __$ChatComponentStateCopyWithImpl<$Res>
|
|||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$Res call({
|
||||
Object? chatKey = null,
|
||||
Object? scrollController = null,
|
||||
Object? textEditingController = null,
|
||||
Object? localUser = freezed,
|
||||
Object? remoteUsers = null,
|
||||
Object? historicalRemoteUsers = null,
|
||||
|
@ -311,18 +260,6 @@ class __$ChatComponentStateCopyWithImpl<$Res>
|
|||
Object? title = null,
|
||||
}) {
|
||||
return _then(_ChatComponentState(
|
||||
chatKey: null == chatKey
|
||||
? _self.chatKey
|
||||
: chatKey // ignore: cast_nullable_to_non_nullable
|
||||
as GlobalKey<ChatState>,
|
||||
scrollController: null == scrollController
|
||||
? _self.scrollController
|
||||
: scrollController // ignore: cast_nullable_to_non_nullable
|
||||
as AutoScrollController,
|
||||
textEditingController: null == textEditingController
|
||||
? _self.textEditingController
|
||||
: textEditingController // ignore: cast_nullable_to_non_nullable
|
||||
as InputTextFieldController,
|
||||
localUser: freezed == localUser
|
||||
? _self.localUser
|
||||
: localUser // ignore: cast_nullable_to_non_nullable
|
||||
|
@ -330,15 +267,15 @@ class __$ChatComponentStateCopyWithImpl<$Res>
|
|||
remoteUsers: null == remoteUsers
|
||||
? _self.remoteUsers
|
||||
: remoteUsers // ignore: cast_nullable_to_non_nullable
|
||||
as IMap<Typed<FixedEncodedString43>, User>,
|
||||
as IMap<String, User>,
|
||||
historicalRemoteUsers: null == historicalRemoteUsers
|
||||
? _self.historicalRemoteUsers
|
||||
: historicalRemoteUsers // ignore: cast_nullable_to_non_nullable
|
||||
as IMap<Typed<FixedEncodedString43>, User>,
|
||||
as IMap<String, User>,
|
||||
unknownUsers: null == unknownUsers
|
||||
? _self.unknownUsers
|
||||
: unknownUsers // ignore: cast_nullable_to_non_nullable
|
||||
as IMap<Typed<FixedEncodedString43>, User>,
|
||||
as IMap<String, User>,
|
||||
messageWindow: null == messageWindow
|
||||
? _self.messageWindow
|
||||
: messageWindow // ignore: cast_nullable_to_non_nullable
|
||||
|
@ -350,6 +287,20 @@ class __$ChatComponentStateCopyWithImpl<$Res>
|
|||
));
|
||||
}
|
||||
|
||||
/// Create a copy of ChatComponentState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$UserCopyWith<$Res>? get localUser {
|
||||
if (_self.localUser == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $UserCopyWith<$Res>(_self.localUser!, (value) {
|
||||
return _then(_self.copyWith(localUser: value));
|
||||
});
|
||||
}
|
||||
|
||||
/// Create a copy of ChatComponentState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
|
|
|
@ -25,7 +25,10 @@ enum MessageSendState {
|
|||
|
||||
@freezed
|
||||
sealed class MessageState with _$MessageState {
|
||||
@JsonSerializable()
|
||||
const factory MessageState({
|
||||
// Sequence number of the message for display purposes
|
||||
required int seqId,
|
||||
// Content of the message
|
||||
@JsonKey(fromJson: messageFromJson, toJson: messageToJson)
|
||||
required proto.Message content,
|
||||
|
|
|
@ -15,7 +15,8 @@ T _$identity<T>(T value) => value;
|
|||
|
||||
/// @nodoc
|
||||
mixin _$MessageState implements DiagnosticableTreeMixin {
|
||||
// Content of the message
|
||||
// Sequence number of the message for display purposes
|
||||
int get seqId; // Content of the message
|
||||
@JsonKey(fromJson: messageFromJson, toJson: messageToJson)
|
||||
proto.Message get content; // Sent timestamp
|
||||
Timestamp get sentTimestamp; // Reconciled timestamp
|
||||
|
@ -37,6 +38,7 @@ mixin _$MessageState implements DiagnosticableTreeMixin {
|
|||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
properties
|
||||
..add(DiagnosticsProperty('type', 'MessageState'))
|
||||
..add(DiagnosticsProperty('seqId', seqId))
|
||||
..add(DiagnosticsProperty('content', content))
|
||||
..add(DiagnosticsProperty('sentTimestamp', sentTimestamp))
|
||||
..add(DiagnosticsProperty('reconciledTimestamp', reconciledTimestamp))
|
||||
|
@ -48,6 +50,7 @@ mixin _$MessageState implements DiagnosticableTreeMixin {
|
|||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is MessageState &&
|
||||
(identical(other.seqId, seqId) || other.seqId == seqId) &&
|
||||
(identical(other.content, content) || other.content == content) &&
|
||||
(identical(other.sentTimestamp, sentTimestamp) ||
|
||||
other.sentTimestamp == sentTimestamp) &&
|
||||
|
@ -59,12 +62,12 @@ mixin _$MessageState implements DiagnosticableTreeMixin {
|
|||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType, content, sentTimestamp, reconciledTimestamp, sendState);
|
||||
int get hashCode => Object.hash(runtimeType, seqId, content, sentTimestamp,
|
||||
reconciledTimestamp, sendState);
|
||||
|
||||
@override
|
||||
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
|
||||
return 'MessageState(content: $content, sentTimestamp: $sentTimestamp, reconciledTimestamp: $reconciledTimestamp, sendState: $sendState)';
|
||||
return 'MessageState(seqId: $seqId, content: $content, sentTimestamp: $sentTimestamp, reconciledTimestamp: $reconciledTimestamp, sendState: $sendState)';
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -75,7 +78,8 @@ abstract mixin class $MessageStateCopyWith<$Res> {
|
|||
_$MessageStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call(
|
||||
{@JsonKey(fromJson: messageFromJson, toJson: messageToJson)
|
||||
{int seqId,
|
||||
@JsonKey(fromJson: messageFromJson, toJson: messageToJson)
|
||||
proto.Message content,
|
||||
Timestamp sentTimestamp,
|
||||
Timestamp? reconciledTimestamp,
|
||||
|
@ -94,12 +98,17 @@ class _$MessageStateCopyWithImpl<$Res> implements $MessageStateCopyWith<$Res> {
|
|||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? seqId = null,
|
||||
Object? content = null,
|
||||
Object? sentTimestamp = null,
|
||||
Object? reconciledTimestamp = freezed,
|
||||
Object? sendState = freezed,
|
||||
}) {
|
||||
return _then(_self.copyWith(
|
||||
seqId: null == seqId
|
||||
? _self.seqId
|
||||
: seqId // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
content: null == content
|
||||
? _self.content
|
||||
: content // ignore: cast_nullable_to_non_nullable
|
||||
|
@ -121,10 +130,12 @@ class _$MessageStateCopyWithImpl<$Res> implements $MessageStateCopyWith<$Res> {
|
|||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
@JsonSerializable()
|
||||
class _MessageState with DiagnosticableTreeMixin implements MessageState {
|
||||
const _MessageState(
|
||||
{@JsonKey(fromJson: messageFromJson, toJson: messageToJson)
|
||||
{required this.seqId,
|
||||
@JsonKey(fromJson: messageFromJson, toJson: messageToJson)
|
||||
required this.content,
|
||||
required this.sentTimestamp,
|
||||
required this.reconciledTimestamp,
|
||||
|
@ -132,6 +143,9 @@ class _MessageState with DiagnosticableTreeMixin implements MessageState {
|
|||
factory _MessageState.fromJson(Map<String, dynamic> json) =>
|
||||
_$MessageStateFromJson(json);
|
||||
|
||||
// Sequence number of the message for display purposes
|
||||
@override
|
||||
final int seqId;
|
||||
// Content of the message
|
||||
@override
|
||||
@JsonKey(fromJson: messageFromJson, toJson: messageToJson)
|
||||
|
@ -165,6 +179,7 @@ class _MessageState with DiagnosticableTreeMixin implements MessageState {
|
|||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
properties
|
||||
..add(DiagnosticsProperty('type', 'MessageState'))
|
||||
..add(DiagnosticsProperty('seqId', seqId))
|
||||
..add(DiagnosticsProperty('content', content))
|
||||
..add(DiagnosticsProperty('sentTimestamp', sentTimestamp))
|
||||
..add(DiagnosticsProperty('reconciledTimestamp', reconciledTimestamp))
|
||||
|
@ -176,6 +191,7 @@ class _MessageState with DiagnosticableTreeMixin implements MessageState {
|
|||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _MessageState &&
|
||||
(identical(other.seqId, seqId) || other.seqId == seqId) &&
|
||||
(identical(other.content, content) || other.content == content) &&
|
||||
(identical(other.sentTimestamp, sentTimestamp) ||
|
||||
other.sentTimestamp == sentTimestamp) &&
|
||||
|
@ -187,12 +203,12 @@ class _MessageState with DiagnosticableTreeMixin implements MessageState {
|
|||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType, content, sentTimestamp, reconciledTimestamp, sendState);
|
||||
int get hashCode => Object.hash(runtimeType, seqId, content, sentTimestamp,
|
||||
reconciledTimestamp, sendState);
|
||||
|
||||
@override
|
||||
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
|
||||
return 'MessageState(content: $content, sentTimestamp: $sentTimestamp, reconciledTimestamp: $reconciledTimestamp, sendState: $sendState)';
|
||||
return 'MessageState(seqId: $seqId, content: $content, sentTimestamp: $sentTimestamp, reconciledTimestamp: $reconciledTimestamp, sendState: $sendState)';
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -205,7 +221,8 @@ abstract mixin class _$MessageStateCopyWith<$Res>
|
|||
@override
|
||||
@useResult
|
||||
$Res call(
|
||||
{@JsonKey(fromJson: messageFromJson, toJson: messageToJson)
|
||||
{int seqId,
|
||||
@JsonKey(fromJson: messageFromJson, toJson: messageToJson)
|
||||
proto.Message content,
|
||||
Timestamp sentTimestamp,
|
||||
Timestamp? reconciledTimestamp,
|
||||
|
@ -225,12 +242,17 @@ class __$MessageStateCopyWithImpl<$Res>
|
|||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$Res call({
|
||||
Object? seqId = null,
|
||||
Object? content = null,
|
||||
Object? sentTimestamp = null,
|
||||
Object? reconciledTimestamp = freezed,
|
||||
Object? sendState = freezed,
|
||||
}) {
|
||||
return _then(_MessageState(
|
||||
seqId: null == seqId
|
||||
? _self.seqId
|
||||
: seqId // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
content: null == content
|
||||
? _self.content
|
||||
: content // ignore: cast_nullable_to_non_nullable
|
||||
|
|
|
@ -8,6 +8,7 @@ part of 'message_state.dart';
|
|||
|
||||
_MessageState _$MessageStateFromJson(Map<String, dynamic> json) =>
|
||||
_MessageState(
|
||||
seqId: (json['seq_id'] as num).toInt(),
|
||||
content: messageFromJson(json['content'] as Map<String, dynamic>),
|
||||
sentTimestamp: Timestamp.fromJson(json['sent_timestamp']),
|
||||
reconciledTimestamp: json['reconciled_timestamp'] == null
|
||||
|
@ -20,6 +21,7 @@ _MessageState _$MessageStateFromJson(Map<String, dynamic> json) =>
|
|||
|
||||
Map<String, dynamic> _$MessageStateToJson(_MessageState instance) =>
|
||||
<String, dynamic>{
|
||||
'seq_id': instance.seqId,
|
||||
'content': messageToJson(instance.content),
|
||||
'sent_timestamp': instance.sentTimestamp.toJson(),
|
||||
'reconciled_timestamp': instance.reconciledTimestamp?.toJson(),
|
||||
|
|
2
lib/chat/views/chat_builders/chat_builders.dart
Normal file
2
lib/chat/views/chat_builders/chat_builders.dart
Normal file
|
@ -0,0 +1,2 @@
|
|||
export 'vc_composer_widget.dart';
|
||||
export 'vc_text_message_widget.dart';
|
431
lib/chat/views/chat_builders/vc_composer_widget.dart
Normal file
431
lib/chat/views/chat_builders/vc_composer_widget.dart
Normal file
|
@ -0,0 +1,431 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
||||
// Typedefs need to come out
|
||||
// ignore: implementation_imports
|
||||
import 'package:flutter_chat_ui/src/utils/typedefs.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../theme/theme.dart';
|
||||
import '../../chat.dart';
|
||||
|
||||
enum ShiftEnterAction { newline, send }
|
||||
|
||||
/// The message composer widget positioned at the bottom of the chat screen.
|
||||
///
|
||||
/// Includes a text input field, an optional attachment button,
|
||||
/// and a send button.
|
||||
class VcComposerWidget extends StatefulWidget {
|
||||
/// Creates a message composer widget.
|
||||
const VcComposerWidget({
|
||||
super.key,
|
||||
this.textEditingController,
|
||||
this.left = 0,
|
||||
this.right = 0,
|
||||
this.top,
|
||||
this.bottom = 0,
|
||||
this.sigmaX = 20,
|
||||
this.sigmaY = 20,
|
||||
this.padding = const EdgeInsets.all(8),
|
||||
this.attachmentIcon = const Icon(Icons.attachment),
|
||||
this.sendIcon = const Icon(Icons.send),
|
||||
this.gap = 8,
|
||||
this.inputBorder,
|
||||
this.filled,
|
||||
this.topWidget,
|
||||
this.handleSafeArea = true,
|
||||
this.backgroundColor,
|
||||
this.attachmentIconColor,
|
||||
this.sendIconColor,
|
||||
this.hintColor,
|
||||
this.textColor,
|
||||
this.inputFillColor,
|
||||
this.hintText = 'Type a message',
|
||||
this.keyboardAppearance,
|
||||
this.autocorrect,
|
||||
this.autofocus = false,
|
||||
this.textCapitalization = TextCapitalization.sentences,
|
||||
this.keyboardType,
|
||||
this.textInputAction = TextInputAction.newline,
|
||||
this.shiftEnterAction = ShiftEnterAction.send,
|
||||
this.focusNode,
|
||||
this.maxLength,
|
||||
this.minLines = 1,
|
||||
this.maxLines = 3,
|
||||
});
|
||||
|
||||
/// Optional controller for the text input field.
|
||||
final TextEditingController? textEditingController;
|
||||
|
||||
/// Optional left position.
|
||||
final double? left;
|
||||
|
||||
/// Optional right position.
|
||||
final double? right;
|
||||
|
||||
/// Optional top position.
|
||||
final double? top;
|
||||
|
||||
/// Optional bottom position.
|
||||
final double? bottom;
|
||||
|
||||
/// Optional X blur value for the background (if using glassmorphism).
|
||||
final double? sigmaX;
|
||||
|
||||
/// Optional Y blur value for the background (if using glassmorphism).
|
||||
final double? sigmaY;
|
||||
|
||||
/// Padding around the composer content.
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
/// Icon for the attachment button. Defaults to [Icons.attachment].
|
||||
final Widget? attachmentIcon;
|
||||
|
||||
/// Icon for the send button. Defaults to [Icons.send].
|
||||
final Widget? sendIcon;
|
||||
|
||||
/// Horizontal gap between elements (attachment icon, text field, send icon).
|
||||
final double? gap;
|
||||
|
||||
/// Border style for the text input field.
|
||||
final InputBorder? inputBorder;
|
||||
|
||||
/// Whether the text input field should be filled.
|
||||
final bool? filled;
|
||||
|
||||
/// Optional widget to display above the main composer row.
|
||||
final Widget? topWidget;
|
||||
|
||||
/// Whether to adjust padding for the bottom safe area.
|
||||
final bool handleSafeArea;
|
||||
|
||||
/// Background color of the composer container.
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Color of the attachment icon.
|
||||
final Color? attachmentIconColor;
|
||||
|
||||
/// Color of the send icon.
|
||||
final Color? sendIconColor;
|
||||
|
||||
/// Color of the hint text in the input field.
|
||||
final Color? hintColor;
|
||||
|
||||
/// Color of the text entered in the input field.
|
||||
final Color? textColor;
|
||||
|
||||
/// Fill color for the text input field when [filled] is true.
|
||||
final Color? inputFillColor;
|
||||
|
||||
/// Placeholder text for the input field.
|
||||
final String? hintText;
|
||||
|
||||
/// Appearance of the keyboard.
|
||||
final Brightness? keyboardAppearance;
|
||||
|
||||
/// Whether to enable autocorrect for the input field.
|
||||
final bool? autocorrect;
|
||||
|
||||
/// Whether the input field should autofocus.
|
||||
final bool autofocus;
|
||||
|
||||
/// Capitalization behavior for the input field.
|
||||
final TextCapitalization textCapitalization;
|
||||
|
||||
/// Type of keyboard to display.
|
||||
final TextInputType? keyboardType;
|
||||
|
||||
/// Action button type for the keyboard (e.g., newline, send).
|
||||
final TextInputAction textInputAction;
|
||||
|
||||
/// Action when shift-enter is pressed (e.g., newline, send).
|
||||
final ShiftEnterAction shiftEnterAction;
|
||||
|
||||
/// Focus node for the text input field.
|
||||
final FocusNode? focusNode;
|
||||
|
||||
/// Maximum character length for the input field.
|
||||
final int? maxLength;
|
||||
|
||||
/// Minimum number of lines for the input field.
|
||||
final int? minLines;
|
||||
|
||||
/// Maximum number of lines the input field can expand to.
|
||||
final int? maxLines;
|
||||
|
||||
@override
|
||||
State<VcComposerWidget> createState() => _VcComposerState();
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties
|
||||
..add(DiagnosticsProperty<TextEditingController?>(
|
||||
'textEditingController', textEditingController))
|
||||
..add(DoubleProperty('left', left))
|
||||
..add(DoubleProperty('right', right))
|
||||
..add(DoubleProperty('top', top))
|
||||
..add(DoubleProperty('bottom', bottom))
|
||||
..add(DoubleProperty('sigmaX', sigmaX))
|
||||
..add(DoubleProperty('sigmaY', sigmaY))
|
||||
..add(DiagnosticsProperty<EdgeInsetsGeometry?>('padding', padding))
|
||||
..add(DoubleProperty('gap', gap))
|
||||
..add(DiagnosticsProperty<InputBorder?>('inputBorder', inputBorder))
|
||||
..add(DiagnosticsProperty<bool?>('filled', filled))
|
||||
..add(DiagnosticsProperty<bool>('handleSafeArea', handleSafeArea))
|
||||
..add(ColorProperty('backgroundColor', backgroundColor))
|
||||
..add(ColorProperty('attachmentIconColor', attachmentIconColor))
|
||||
..add(ColorProperty('sendIconColor', sendIconColor))
|
||||
..add(ColorProperty('hintColor', hintColor))
|
||||
..add(ColorProperty('textColor', textColor))
|
||||
..add(ColorProperty('inputFillColor', inputFillColor))
|
||||
..add(StringProperty('hintText', hintText))
|
||||
..add(EnumProperty<Brightness?>('keyboardAppearance', keyboardAppearance))
|
||||
..add(DiagnosticsProperty<bool?>('autocorrect', autocorrect))
|
||||
..add(DiagnosticsProperty<bool>('autofocus', autofocus))
|
||||
..add(EnumProperty<TextCapitalization>(
|
||||
'textCapitalization', textCapitalization))
|
||||
..add(DiagnosticsProperty<TextInputType?>('keyboardType', keyboardType))
|
||||
..add(EnumProperty<TextInputAction>('textInputAction', textInputAction))
|
||||
..add(
|
||||
EnumProperty<ShiftEnterAction>('shiftEnterAction', shiftEnterAction))
|
||||
..add(DiagnosticsProperty<FocusNode?>('focusNode', focusNode))
|
||||
..add(IntProperty('maxLength', maxLength))
|
||||
..add(IntProperty('minLines', minLines))
|
||||
..add(IntProperty('maxLines', maxLines));
|
||||
}
|
||||
}
|
||||
|
||||
class _VcComposerState extends State<VcComposerWidget> {
|
||||
final _key = GlobalKey();
|
||||
late final TextEditingController _textController;
|
||||
late final FocusNode _focusNode;
|
||||
late String _suffixText;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textController = widget.textEditingController ?? TextEditingController();
|
||||
_focusNode = widget.focusNode ?? FocusNode();
|
||||
_focusNode.onKeyEvent = _handleKeyEvent;
|
||||
_updateSuffixText();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _measure());
|
||||
}
|
||||
|
||||
void _updateSuffixText() {
|
||||
final utf8Length = utf8.encode(_textController.text).length;
|
||||
_suffixText = '$utf8Length/${widget.maxLength}';
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
// Check for Shift+Enter
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.enter &&
|
||||
HardwareKeyboard.instance.isShiftPressed) {
|
||||
if (widget.shiftEnterAction == ShiftEnterAction.send) {
|
||||
_handleSubmitted(_textController.text);
|
||||
return KeyEventResult.handled;
|
||||
} else if (widget.shiftEnterAction == ShiftEnterAction.newline) {
|
||||
final val = _textController.value;
|
||||
final insertOffset = val.selection.extent.offset;
|
||||
final messageWithNewLine =
|
||||
'${_textController.text.substring(0, insertOffset)}\n'
|
||||
'${_textController.text.substring(insertOffset)}';
|
||||
_textController.value = TextEditingValue(
|
||||
text: messageWithNewLine,
|
||||
selection: TextSelection.fromPosition(
|
||||
TextPosition(offset: insertOffset + 1),
|
||||
),
|
||||
);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant VcComposerWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _measure());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Only try to dispose text controller if it's not provided, let
|
||||
// user handle disposing it how they want.
|
||||
if (widget.textEditingController == null) {
|
||||
_textController.dispose();
|
||||
}
|
||||
if (widget.focusNode == null) {
|
||||
_focusNode.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomSafeArea =
|
||||
widget.handleSafeArea ? MediaQuery.of(context).padding.bottom : 0.0;
|
||||
final onAttachmentTap = context.read<OnAttachmentTapCallback?>();
|
||||
final theme = Theme.of(context);
|
||||
final scaleTheme = theme.extension<ScaleTheme>()!;
|
||||
final config = scaleTheme.config;
|
||||
final scheme = scaleTheme.scheme;
|
||||
final scale = scaleTheme.scheme.scale(ScaleKind.primary);
|
||||
final textTheme = theme.textTheme;
|
||||
final scaleChatTheme = scaleTheme.chatTheme();
|
||||
final chatTheme = scaleChatTheme.chatTheme;
|
||||
|
||||
final suffixTextStyle =
|
||||
textTheme.bodySmall!.copyWith(color: scale.subtleText);
|
||||
|
||||
return Positioned(
|
||||
left: widget.left,
|
||||
right: widget.right,
|
||||
top: widget.top,
|
||||
bottom: widget.bottom,
|
||||
child: ClipRect(
|
||||
child: DecoratedBox(
|
||||
key: _key,
|
||||
decoration: BoxDecoration(
|
||||
border: config.preferBorders
|
||||
? Border(top: BorderSide(color: scale.border, width: 2))
|
||||
: null,
|
||||
color: config.preferBorders
|
||||
? scale.elementBackground
|
||||
: scale.border),
|
||||
child: Column(
|
||||
children: [
|
||||
if (widget.topWidget != null) widget.topWidget!,
|
||||
Padding(
|
||||
padding: widget.handleSafeArea
|
||||
? (widget.padding?.add(
|
||||
EdgeInsets.only(bottom: bottomSafeArea),
|
||||
) ??
|
||||
EdgeInsets.only(bottom: bottomSafeArea))
|
||||
: (widget.padding ?? EdgeInsets.zero),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.attachmentIcon != null &&
|
||||
onAttachmentTap != null)
|
||||
IconButton(
|
||||
icon: widget.attachmentIcon!,
|
||||
color: widget.attachmentIconColor ??
|
||||
chatTheme.colors.onSurface.withValues(alpha: 0.5),
|
||||
onPressed: onAttachmentTap,
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
SizedBox(width: widget.gap),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
decoration: InputDecoration(
|
||||
filled: widget.filled ?? !config.preferBorders,
|
||||
fillColor: widget.inputFillColor ??
|
||||
scheme.primaryScale.subtleBackground,
|
||||
isDense: true,
|
||||
contentPadding:
|
||||
const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderSide: config.preferBorders
|
||||
? BorderSide(
|
||||
color: scheme.grayScale.border,
|
||||
width: 2)
|
||||
: BorderSide.none,
|
||||
borderRadius: BorderRadius.all(Radius.circular(
|
||||
8 * config.borderRadiusScale))),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: config.preferBorders
|
||||
? BorderSide(
|
||||
color: scheme.primaryScale.border,
|
||||
width: 2)
|
||||
: BorderSide.none,
|
||||
borderRadius: BorderRadius.all(Radius.circular(
|
||||
8 * config.borderRadiusScale))),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: config.preferBorders
|
||||
? BorderSide(
|
||||
color: scheme.primaryScale.border,
|
||||
width: 2)
|
||||
: BorderSide.none,
|
||||
borderRadius: BorderRadius.all(Radius.circular(
|
||||
8 * config.borderRadiusScale))),
|
||||
hintText: widget.hintText,
|
||||
hintStyle: chatTheme.typography.bodyMedium.copyWith(
|
||||
color: widget.hintColor ??
|
||||
chatTheme.colors.onSurface
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
border: widget.inputBorder,
|
||||
hoverColor: Colors.transparent,
|
||||
suffix: Text(_suffixText, style: suffixTextStyle)),
|
||||
onSubmitted: _handleSubmitted,
|
||||
onChanged: (value) {
|
||||
setState(_updateSuffixText);
|
||||
},
|
||||
textInputAction: widget.textInputAction,
|
||||
keyboardAppearance: widget.keyboardAppearance,
|
||||
autocorrect: widget.autocorrect ?? true,
|
||||
autofocus: widget.autofocus,
|
||||
textCapitalization: widget.textCapitalization,
|
||||
keyboardType: widget.keyboardType,
|
||||
focusNode: _focusNode,
|
||||
//maxLength: widget.maxLength,
|
||||
minLines: widget.minLines,
|
||||
maxLines: widget.maxLines,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.none,
|
||||
inputFormatters: [
|
||||
Utf8LengthLimitingTextInputFormatter(
|
||||
maxLength: widget.maxLength),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: widget.gap),
|
||||
if ((widget.sendIcon ?? scaleChatTheme.sendButtonIcon) !=
|
||||
null)
|
||||
IconButton(
|
||||
icon:
|
||||
(widget.sendIcon ?? scaleChatTheme.sendButtonIcon)!,
|
||||
color: widget.sendIconColor,
|
||||
onPressed: () => _handleSubmitted(_textController.text),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _measure() {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final renderBox = _key.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (renderBox != null) {
|
||||
final height = renderBox.size.height;
|
||||
final bottomSafeArea = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
context.read<ComposerHeightNotifier>().setHeight(
|
||||
// only set real height of the composer, ignoring safe area
|
||||
widget.handleSafeArea ? height - bottomSafeArea : height,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSubmitted(String text) {
|
||||
if (text.isNotEmpty) {
|
||||
context.read<OnMessageSendCallback?>()?.call(text);
|
||||
_textController.clear();
|
||||
}
|
||||
}
|
||||
}
|
269
lib/chat/views/chat_builders/vc_text_message_widget.dart
Normal file
269
lib/chat/views/chat_builders/vc_text_message_widget.dart
Normal file
|
@ -0,0 +1,269 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../theme/theme.dart';
|
||||
import '../date_formatter.dart';
|
||||
|
||||
/// A widget that displays a text message.
|
||||
class VcTextMessageWidget extends StatelessWidget {
|
||||
/// Creates a widget to display a simple text message.
|
||||
const VcTextMessageWidget({
|
||||
required this.message,
|
||||
required this.index,
|
||||
this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
this.borderRadius,
|
||||
this.onlyEmojiFontSize,
|
||||
this.sentBackgroundColor,
|
||||
this.receivedBackgroundColor,
|
||||
this.sentTextStyle,
|
||||
this.receivedTextStyle,
|
||||
this.timeStyle,
|
||||
this.showTime = true,
|
||||
this.showStatus = true,
|
||||
this.timeAndStatusPosition = TimeAndStatusPosition.end,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The text message data model.
|
||||
final TextMessage message;
|
||||
|
||||
/// The index of the message in the list.
|
||||
final int index;
|
||||
|
||||
/// Padding around the message bubble content.
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
/// Border radius of the message bubble.
|
||||
final BorderRadiusGeometry? borderRadius;
|
||||
|
||||
/// Font size for messages containing only emojis.
|
||||
final double? onlyEmojiFontSize;
|
||||
|
||||
/// Background color for messages sent by the current user.
|
||||
final Color? sentBackgroundColor;
|
||||
|
||||
/// Background color for messages received from other users.
|
||||
final Color? receivedBackgroundColor;
|
||||
|
||||
/// Text style for messages sent by the current user.
|
||||
final TextStyle? sentTextStyle;
|
||||
|
||||
/// Text style for messages received from other users.
|
||||
final TextStyle? receivedTextStyle;
|
||||
|
||||
/// Text style for the message timestamp and status.
|
||||
final TextStyle? timeStyle;
|
||||
|
||||
/// Whether to display the message timestamp.
|
||||
final bool showTime;
|
||||
|
||||
/// Whether to display the message status (sent, delivered, seen)
|
||||
/// for sent messages.
|
||||
final bool showStatus;
|
||||
|
||||
/// Position of the timestamp and status indicator relative to the text.
|
||||
final TimeAndStatusPosition timeAndStatusPosition;
|
||||
|
||||
bool get _isOnlyEmoji => message.metadata?['isOnlyEmoji'] == true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scaleTheme = theme.extension<ScaleTheme>()!;
|
||||
final config = scaleTheme.config;
|
||||
final scheme = scaleTheme.scheme;
|
||||
final scale = scaleTheme.scheme.scale(ScaleKind.primary);
|
||||
final textTheme = theme.textTheme;
|
||||
final scaleChatTheme = scaleTheme.chatTheme();
|
||||
final chatTheme = scaleChatTheme.chatTheme;
|
||||
|
||||
final isSentByMe = context.watch<UserID>() == message.authorId;
|
||||
final backgroundColor = _resolveBackgroundColor(isSentByMe, scaleChatTheme);
|
||||
final textStyle = _resolveTextStyle(isSentByMe, scaleChatTheme);
|
||||
final timeStyle = _resolveTimeStyle(isSentByMe, scaleChatTheme);
|
||||
final emojiFontSize = onlyEmojiFontSize ?? scaleChatTheme.onlyEmojiFontSize;
|
||||
|
||||
final timeAndStatus = showTime || (isSentByMe && showStatus)
|
||||
? TimeAndStatus(
|
||||
time: message.time,
|
||||
status: message.status,
|
||||
showTime: showTime,
|
||||
showStatus: isSentByMe && showStatus,
|
||||
textStyle: timeStyle,
|
||||
)
|
||||
: null;
|
||||
|
||||
final textContent = Text(
|
||||
message.text,
|
||||
style: _isOnlyEmoji
|
||||
? textStyle.copyWith(fontSize: emojiFontSize)
|
||||
: textStyle,
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: _isOnlyEmoji
|
||||
? EdgeInsets.symmetric(
|
||||
horizontal: (padding?.horizontal ?? 0) / 2,
|
||||
// vertical: 0,
|
||||
)
|
||||
: padding,
|
||||
decoration: _isOnlyEmoji
|
||||
? null
|
||||
: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: borderRadius ?? chatTheme.shape,
|
||||
),
|
||||
child: _buildContentBasedOnPosition(
|
||||
context: context,
|
||||
textContent: textContent,
|
||||
timeAndStatus: timeAndStatus,
|
||||
textStyle: textStyle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContentBasedOnPosition({
|
||||
required BuildContext context,
|
||||
required Widget textContent,
|
||||
TimeAndStatus? timeAndStatus,
|
||||
TextStyle? textStyle,
|
||||
}) {
|
||||
if (timeAndStatus == null) {
|
||||
return textContent;
|
||||
}
|
||||
|
||||
switch (timeAndStatusPosition) {
|
||||
case TimeAndStatusPosition.start:
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [textContent, timeAndStatus],
|
||||
);
|
||||
case TimeAndStatusPosition.inline:
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(child: textContent),
|
||||
const SizedBox(width: 4),
|
||||
timeAndStatus,
|
||||
],
|
||||
);
|
||||
case TimeAndStatusPosition.end:
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [textContent, timeAndStatus],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Color _resolveBackgroundColor(bool isSentByMe, ScaleChatTheme theme) {
|
||||
if (isSentByMe) {
|
||||
return sentBackgroundColor ?? theme.primaryColor;
|
||||
}
|
||||
return receivedBackgroundColor ?? theme.secondaryColor;
|
||||
}
|
||||
|
||||
TextStyle _resolveTextStyle(bool isSentByMe, ScaleChatTheme theme) {
|
||||
if (isSentByMe) {
|
||||
return sentTextStyle ?? theme.sentMessageBodyTextStyle;
|
||||
}
|
||||
return receivedTextStyle ?? theme.receivedMessageBodyTextStyle;
|
||||
}
|
||||
|
||||
TextStyle _resolveTimeStyle(bool isSentByMe, ScaleChatTheme theme) {
|
||||
final ts = _resolveTextStyle(isSentByMe, theme);
|
||||
|
||||
return theme.timeStyle.copyWith(color: ts.color);
|
||||
}
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties
|
||||
..add(DiagnosticsProperty<TextMessage>('message', message))
|
||||
..add(IntProperty('index', index))
|
||||
..add(DiagnosticsProperty<EdgeInsetsGeometry?>('padding', padding))
|
||||
..add(DiagnosticsProperty<BorderRadiusGeometry?>(
|
||||
'borderRadius', borderRadius))
|
||||
..add(DoubleProperty('onlyEmojiFontSize', onlyEmojiFontSize))
|
||||
..add(ColorProperty('sentBackgroundColor', sentBackgroundColor))
|
||||
..add(ColorProperty('receivedBackgroundColor', receivedBackgroundColor))
|
||||
..add(DiagnosticsProperty<TextStyle?>('sentTextStyle', sentTextStyle))
|
||||
..add(DiagnosticsProperty<TextStyle?>(
|
||||
'receivedTextStyle', receivedTextStyle))
|
||||
..add(DiagnosticsProperty<TextStyle?>('timeStyle', timeStyle))
|
||||
..add(DiagnosticsProperty<bool>('showTime', showTime))
|
||||
..add(DiagnosticsProperty<bool>('showStatus', showStatus))
|
||||
..add(EnumProperty<TimeAndStatusPosition>(
|
||||
'timeAndStatusPosition', timeAndStatusPosition));
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget to display the message timestamp and status indicator.
|
||||
class TimeAndStatus extends StatelessWidget {
|
||||
/// Creates a widget for displaying time and status.
|
||||
const TimeAndStatus({
|
||||
required this.time,
|
||||
this.status,
|
||||
this.showTime = true,
|
||||
this.showStatus = true,
|
||||
this.textStyle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The time the message was created.
|
||||
final DateTime? time;
|
||||
|
||||
/// The status of the message.
|
||||
final MessageStatus? status;
|
||||
|
||||
/// Whether to display the timestamp.
|
||||
final bool showTime;
|
||||
|
||||
/// Whether to display the status indicator.
|
||||
final bool showStatus;
|
||||
|
||||
/// The text style for the time and status.
|
||||
final TextStyle? textStyle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dformat = DateFormatter();
|
||||
|
||||
return Row(
|
||||
spacing: 2,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showTime && time != null)
|
||||
Text(dformat.chatDateTimeFormat(time!.toLocal()), style: textStyle),
|
||||
if (showStatus && status != null)
|
||||
if (status == MessageStatus.sending)
|
||||
SizedBox(
|
||||
width: 6,
|
||||
height: 6,
|
||||
child: CircularProgressIndicator(
|
||||
color: textStyle?.color,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(getIconForStatus(status!), color: textStyle?.color, size: 12),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties
|
||||
..add(DiagnosticsProperty<DateTime?>('time', time))
|
||||
..add(EnumProperty<MessageStatus?>('status', status))
|
||||
..add(DiagnosticsProperty<bool>('showTime', showTime))
|
||||
..add(DiagnosticsProperty<bool>('showStatus', showStatus))
|
||||
..add(DiagnosticsProperty<TextStyle?>('textStyle', textStyle));
|
||||
}
|
||||
}
|
|
@ -1,11 +1,13 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:async_tools/async_tools.dart';
|
||||
import 'package:awesome_extensions/awesome_extensions.dart';
|
||||
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart' as core;
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
||||
import 'package:flutter_translate/flutter_translate.dart';
|
||||
import 'package:veilid_support/veilid_support.dart';
|
||||
|
@ -16,11 +18,15 @@ import '../../conversation/conversation.dart';
|
|||
import '../../notifications/notifications.dart';
|
||||
import '../../theme/theme.dart';
|
||||
import '../chat.dart';
|
||||
import 'chat_builders/chat_builders.dart';
|
||||
|
||||
const onEndReachedThreshold = 0.75;
|
||||
const _kScrollTag = 'kScrollTag';
|
||||
const kSeqId = 'seqId';
|
||||
const maxMessageLength = 2048;
|
||||
|
||||
class ChatComponentWidget extends StatelessWidget {
|
||||
const ChatComponentWidget({
|
||||
class ChatComponentWidget extends StatefulWidget {
|
||||
const ChatComponentWidget._({
|
||||
required super.key,
|
||||
required TypedKey localConversationRecordKey,
|
||||
required void Function() onCancel,
|
||||
|
@ -29,10 +35,14 @@ class ChatComponentWidget extends StatelessWidget {
|
|||
_onCancel = onCancel,
|
||||
_onClose = onClose;
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Create a single-contact chat and its associated state
|
||||
static Widget singleContact({
|
||||
required BuildContext context,
|
||||
required TypedKey localConversationRecordKey,
|
||||
required void Function() onCancel,
|
||||
required void Function() onClose,
|
||||
Key? key,
|
||||
}) {
|
||||
// Get the account info
|
||||
final accountInfo = context.watch<AccountInfoCubit>().state;
|
||||
|
||||
|
@ -45,19 +55,19 @@ class ChatComponentWidget extends StatelessWidget {
|
|||
// Get the active conversation cubit
|
||||
final activeConversationCubit = context
|
||||
.select<ActiveConversationsBlocMapCubit, ActiveConversationCubit?>(
|
||||
(x) => x.tryOperateSync(_localConversationRecordKey,
|
||||
(x) => x.tryOperateSync(localConversationRecordKey,
|
||||
closure: (cubit) => cubit));
|
||||
if (activeConversationCubit == null) {
|
||||
return waitingPage(onCancel: _onCancel);
|
||||
return waitingPage(onCancel: onCancel);
|
||||
}
|
||||
|
||||
// Get the messages cubit
|
||||
final messagesCubit = context.select<ActiveSingleContactChatBlocMapCubit,
|
||||
SingleContactMessagesCubit?>(
|
||||
(x) => x.tryOperateSync(_localConversationRecordKey,
|
||||
(x) => x.tryOperateSync(localConversationRecordKey,
|
||||
closure: (cubit) => cubit));
|
||||
if (messagesCubit == null) {
|
||||
return waitingPage(onCancel: _onCancel);
|
||||
return waitingPage(onCancel: onCancel);
|
||||
}
|
||||
|
||||
// Make chat component state
|
||||
|
@ -70,26 +80,65 @@ class ChatComponentWidget extends StatelessWidget {
|
|||
activeConversationCubit: activeConversationCubit,
|
||||
messagesCubit: messagesCubit,
|
||||
),
|
||||
child: Builder(builder: _buildChatComponent));
|
||||
child: ChatComponentWidget._(
|
||||
key: ValueKey(localConversationRecordKey),
|
||||
localConversationRecordKey: localConversationRecordKey,
|
||||
onCancel: onCancel,
|
||||
onClose: onClose));
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
@override
|
||||
State<ChatComponentWidget> createState() => _ChatComponentWidgetState();
|
||||
|
||||
Widget _buildChatComponent(BuildContext context) {
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
final TypedKey _localConversationRecordKey;
|
||||
final void Function() _onCancel;
|
||||
final void Function() _onClose;
|
||||
}
|
||||
|
||||
class _ChatComponentWidgetState extends State<ChatComponentWidget> {
|
||||
////////////////////////////////////////////////////////////////////
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_chatController = core.InMemoryChatController();
|
||||
_textEditingController = TextEditingController();
|
||||
_scrollController = ScrollController();
|
||||
_chatStateProcessor = SingleStateProcessor<ChatComponentState>();
|
||||
|
||||
final _chatComponentCubit = context.read<ChatComponentCubit>();
|
||||
_chatStateProcessor.follow(_chatComponentCubit.stream,
|
||||
_chatComponentCubit.state, _updateChatState);
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_chatStateProcessor.close());
|
||||
|
||||
_chatController.dispose();
|
||||
_scrollController.dispose();
|
||||
_textEditingController.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scaleScheme = theme.extension<ScaleScheme>()!;
|
||||
final scaleConfig = theme.extension<ScaleConfig>()!;
|
||||
final scale = scaleScheme.scale(ScaleKind.primary);
|
||||
final scaleTheme = theme.extension<ScaleTheme>()!;
|
||||
final scale = scaleTheme.scheme.scale(ScaleKind.primary);
|
||||
final textTheme = theme.textTheme;
|
||||
final chatTheme = makeChatTheme(scaleScheme, scaleConfig, textTheme);
|
||||
final errorChatTheme = (ChatThemeEditor(chatTheme)
|
||||
..inputTextColor = scaleScheme.errorScale.primary
|
||||
..sendButtonIcon = Image.asset(
|
||||
'assets/icon-send.png',
|
||||
color: scaleScheme.errorScale.primary,
|
||||
package: 'flutter_chat_ui',
|
||||
))
|
||||
.commit();
|
||||
final scaleChatTheme = scaleTheme.chatTheme();
|
||||
// final errorChatTheme = chatTheme.copyWith(color:)
|
||||
// ..inputTextColor = scaleScheme.errorScale.primary
|
||||
// ..sendButtonIcon = Image.asset(
|
||||
// 'assets/icon-send.png',
|
||||
// color: scaleScheme.errorScale.primary,
|
||||
// package: 'flutter_chat_ui',
|
||||
// ))
|
||||
// .commit();
|
||||
|
||||
// Get the enclosing chat component cubit that contains our state
|
||||
// (created by ChatComponentWidget.builder())
|
||||
|
@ -110,9 +159,8 @@ class ChatComponentWidget extends StatelessWidget {
|
|||
final title = chatComponentState.title;
|
||||
|
||||
if (chatComponentCubit.scrollOffset != 0) {
|
||||
chatComponentState.scrollController.position.correctPixels(
|
||||
chatComponentState.scrollController.position.pixels +
|
||||
chatComponentCubit.scrollOffset);
|
||||
_scrollController.position.correctPixels(
|
||||
_scrollController.position.pixels + chatComponentCubit.scrollOffset);
|
||||
|
||||
chatComponentCubit.scrollOffset = 0;
|
||||
}
|
||||
|
@ -138,7 +186,7 @@ class ChatComponentWidget extends StatelessWidget {
|
|||
IconButton(
|
||||
iconSize: 24,
|
||||
icon: Icon(Icons.close, color: scale.borderText),
|
||||
onPressed: _onClose)
|
||||
onPressed: widget._onClose)
|
||||
.paddingLTRB(0, 0, 8, 0)
|
||||
]),
|
||||
),
|
||||
|
@ -164,7 +212,7 @@ class ChatComponentWidget extends StatelessWidget {
|
|||
chatComponentCubit.scrollOffset = scrollOffset;
|
||||
|
||||
//
|
||||
singleFuture(chatComponentState.chatKey, () async {
|
||||
singleFuture((chatComponentCubit, _kScrollTag), () async {
|
||||
await _handlePageForward(
|
||||
chatComponentCubit, messageWindow, notification);
|
||||
});
|
||||
|
@ -182,7 +230,7 @@ class ChatComponentWidget extends StatelessWidget {
|
|||
|
||||
chatComponentCubit.scrollOffset = scrollOffset;
|
||||
//
|
||||
singleFuture(chatComponentState.chatKey, () async {
|
||||
singleFuture((chatComponentCubit, _kScrollTag), () async {
|
||||
await _handlePageBackward(
|
||||
chatComponentCubit, messageWindow, notification);
|
||||
});
|
||||
|
@ -190,82 +238,181 @@ class ChatComponentWidget extends StatelessWidget {
|
|||
return false;
|
||||
},
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: chatComponentState.textEditingController,
|
||||
valueListenable: _textEditingController,
|
||||
builder: (context, textEditingValue, __) {
|
||||
final messageIsValid =
|
||||
utf8.encode(textEditingValue.text).lengthInBytes <
|
||||
2048;
|
||||
_messageIsValid(textEditingValue.text);
|
||||
var sendIconColor = scaleTheme.config.preferBorders
|
||||
? scale.border
|
||||
: scale.borderText;
|
||||
|
||||
if (!messageIsValid ||
|
||||
_textEditingController.text.isEmpty) {
|
||||
sendIconColor = sendIconColor.withAlpha(128);
|
||||
}
|
||||
|
||||
return Chat(
|
||||
key: chatComponentState.chatKey,
|
||||
theme: messageIsValid ? chatTheme : errorChatTheme,
|
||||
messages: messageWindow.window.toList(),
|
||||
scrollToBottomOnSend: isFirstPage,
|
||||
scrollController: chatComponentState.scrollController,
|
||||
inputOptions: InputOptions(
|
||||
inputClearMode: messageIsValid
|
||||
? InputClearMode.always
|
||||
: InputClearMode.never,
|
||||
textEditingController:
|
||||
chatComponentState.textEditingController),
|
||||
// isLastPage: isLastPage,
|
||||
// onEndReached: () async {
|
||||
// await _handlePageBackward(
|
||||
// chatComponentCubit, messageWindow);
|
||||
// },
|
||||
//onEndReachedThreshold: onEndReachedThreshold,
|
||||
//onAttachmentPressed: _handleAttachmentPressed,
|
||||
//onMessageTap: _handleMessageTap,
|
||||
//onPreviewDataFetched: _handlePreviewDataFetched,
|
||||
usePreviewData: false, //
|
||||
onSendPressed: (pt) {
|
||||
try {
|
||||
if (!messageIsValid) {
|
||||
context.read<NotificationsCubit>().error(
|
||||
text: translate('chat.message_too_long'));
|
||||
return;
|
||||
}
|
||||
_handleSendPressed(chatComponentCubit, pt);
|
||||
} on FormatException {
|
||||
context.read<NotificationsCubit>().error(
|
||||
text: translate('chat.message_too_long'));
|
||||
}
|
||||
},
|
||||
listBottomWidget: messageIsValid
|
||||
? null
|
||||
: Text(translate('chat.message_too_long'),
|
||||
style: TextStyle(
|
||||
color:
|
||||
scaleScheme.errorScale.primary))
|
||||
.toCenter(),
|
||||
//showUserAvatars: false,
|
||||
//showUserNames: true,
|
||||
user: localUser,
|
||||
emptyState: const EmptyChatWidget());
|
||||
currentUserId: localUser.id,
|
||||
resolveUser: (id) async {
|
||||
if (id == localUser.id) {
|
||||
return localUser;
|
||||
}
|
||||
return chatComponentState.remoteUsers.get(id);
|
||||
},
|
||||
chatController: _chatController,
|
||||
onMessageSend: (text) =>
|
||||
_handleSendPressed(chatComponentCubit, text),
|
||||
theme: scaleChatTheme.chatTheme,
|
||||
builders: core.Builders(
|
||||
// Chat list builder
|
||||
chatAnimatedListBuilder: (context, itemBuilder) =>
|
||||
ChatAnimatedListReversed(
|
||||
scrollController: _scrollController,
|
||||
itemBuilder: itemBuilder),
|
||||
// Text message builder
|
||||
textMessageBuilder: (context, message, index) =>
|
||||
VcTextMessageWidget(
|
||||
message: message,
|
||||
index: index,
|
||||
// showTime: true,
|
||||
// showStatus: true,
|
||||
),
|
||||
// Composer builder
|
||||
composerBuilder: (ctx) => VcComposerWidget(
|
||||
autofocus: true,
|
||||
textInputAction: isAnyMobile
|
||||
? TextInputAction.newline
|
||||
: TextInputAction.send,
|
||||
shiftEnterAction: isAnyMobile
|
||||
? ShiftEnterAction.send
|
||||
: ShiftEnterAction.newline,
|
||||
textEditingController: _textEditingController,
|
||||
maxLength: maxMessageLength,
|
||||
keyboardType: TextInputType.multiline,
|
||||
sendIconColor: sendIconColor,
|
||||
topWidget: messageIsValid
|
||||
? null
|
||||
: Text(translate('chat.message_too_long'),
|
||||
style: TextStyle(
|
||||
color: scaleTheme
|
||||
.scheme.errorScale.primary))
|
||||
.toCenter(),
|
||||
),
|
||||
),
|
||||
timeFormat: core.DateFormat.jm(),
|
||||
);
|
||||
}))).expanded(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSendPressed(
|
||||
ChatComponentCubit chatComponentCubit, types.PartialText message) {
|
||||
final text = message.text;
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
|
||||
bool _messageIsValid(String text) =>
|
||||
utf8.encode(text).lengthInBytes < maxMessageLength;
|
||||
|
||||
Future<void> _updateChatState(ChatComponentState chatComponentState) async {
|
||||
// Update message window state
|
||||
final data = chatComponentState.messageWindow.asData;
|
||||
if (data == null) {
|
||||
await _chatController.setMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
final windowState = data.value;
|
||||
|
||||
await _chatController.setMessages(windowState.window.toList());
|
||||
|
||||
// final newMessagesSet = windowState.window.toSet();
|
||||
// final newMessagesById =
|
||||
// Map.fromEntries(newMessagesSet.map((m) => MapEntry(m.id, m)));
|
||||
// final newMessagesBySeqId = Map.fromEntries(
|
||||
// newMessagesSet.map((m) => MapEntry(m.metadata![kSeqId], m)));
|
||||
// final oldMessagesSet = _chatController.messages.toSet();
|
||||
|
||||
// if (oldMessagesSet.isEmpty) {
|
||||
// await _chatController.setMessages(windowState.window.toList());
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // See how many messages differ by equality (not identity)
|
||||
// // If there are more than `replaceAllMessagesThreshold` differences
|
||||
// // just replace the whole list of messages
|
||||
// final diffs = newMessagesSet.diffAndIntersect(oldMessagesSet,
|
||||
// diffThisMinusOther: true, diffOtherMinusThis: true);
|
||||
// final addedMessages = diffs.diffThisMinusOther!;
|
||||
// final removedMessages = diffs.diffOtherMinusThis!;
|
||||
|
||||
// final replaceAllPaginationLimit = windowState.windowCount / 3;
|
||||
|
||||
// if ((addedMessages.length >= replaceAllPaginationLimit) ||
|
||||
// removedMessages.length >= replaceAllPaginationLimit) {
|
||||
// await _chatController.setMessages(windowState.window.toList());
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // Remove messages that are gone, and replace the ones that have changed
|
||||
// for (final m in removedMessages) {
|
||||
// final newm = newMessagesById[m.id];
|
||||
// if (newm != null) {
|
||||
// await _chatController.updateMessage(m, newm);
|
||||
// } else {
|
||||
// final newm = newMessagesBySeqId[m.metadata![kSeqId]];
|
||||
// if (newm != null) {
|
||||
// await _chatController.updateMessage(m, newm);
|
||||
// addedMessages.remove(newm);
|
||||
// } else {
|
||||
// await _chatController.removeMessage(m);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// // // Check for append
|
||||
// if (addedMessages.isNotEmpty) {
|
||||
// if (_chatController.messages.isNotEmpty &&
|
||||
// (addedMessages.first.metadata![kSeqId] as int) >
|
||||
// (_chatController.messages.reversed.last.metadata![kSeqId]
|
||||
// as int)) {
|
||||
// await _chatController.insertAllMessages(addedMessages.reversedView,
|
||||
// index: 0);
|
||||
// }
|
||||
// // Check for prepend
|
||||
// else if (_chatController.messages.isNotEmpty &&
|
||||
// (addedMessages.last.metadata![kSeqId] as int) <
|
||||
// (_chatController.messages.reversed.first.metadata![kSeqId]
|
||||
// as int)) {
|
||||
// await _chatController.insertAllMessages(
|
||||
// addedMessages.reversedView,
|
||||
// );
|
||||
// }
|
||||
// // Otherwise just replace
|
||||
// // xxx could use a better algorithm here to merge added messages in
|
||||
// else {
|
||||
// await _chatController.setMessages(windowState.window.toList());
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
void _handleSendPressed(ChatComponentCubit chatComponentCubit, String text) {
|
||||
if (text.startsWith('/')) {
|
||||
chatComponentCubit.runCommand(text);
|
||||
return;
|
||||
}
|
||||
|
||||
chatComponentCubit.sendMessage(message);
|
||||
if (!_messageIsValid(text)) {
|
||||
context
|
||||
.read<NotificationsCubit>()
|
||||
.error(text: translate('chat.message_too_long'));
|
||||
return;
|
||||
}
|
||||
|
||||
chatComponentCubit.sendMessage(text: text);
|
||||
}
|
||||
|
||||
// void _handleAttachmentPressed() async {
|
||||
// //
|
||||
// }
|
||||
|
||||
Future<void> _handlePageForward(
|
||||
ChatComponentCubit chatComponentCubit,
|
||||
WindowState<types.Message> messageWindow,
|
||||
WindowState<core.Message> messageWindow,
|
||||
ScrollNotification notification) async {
|
||||
debugPrint(
|
||||
'_handlePageForward: messagesState.length=${messageWindow.length} '
|
||||
|
@ -299,7 +446,7 @@ class ChatComponentWidget extends StatelessWidget {
|
|||
|
||||
Future<void> _handlePageBackward(
|
||||
ChatComponentCubit chatComponentCubit,
|
||||
WindowState<types.Message> messageWindow,
|
||||
WindowState<core.Message> messageWindow,
|
||||
ScrollNotification notification,
|
||||
) async {
|
||||
debugPrint(
|
||||
|
@ -335,8 +482,8 @@ class ChatComponentWidget extends StatelessWidget {
|
|||
//chatComponentCubit.scrollOffset = 0;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
final TypedKey _localConversationRecordKey;
|
||||
final void Function() _onCancel;
|
||||
final void Function() _onClose;
|
||||
late final core.ChatController _chatController;
|
||||
late final TextEditingController _textEditingController;
|
||||
late final ScrollController _scrollController;
|
||||
late final SingleStateProcessor<ChatComponentState> _chatStateProcessor;
|
||||
}
|
||||
|
|
41
lib/chat/views/date_formatter.dart
Normal file
41
lib/chat/views/date_formatter.dart
Normal file
|
@ -0,0 +1,41 @@
|
|||
import 'package:flutter_translate/flutter_translate.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class DateFormatter {
|
||||
DateFormatter();
|
||||
|
||||
String chatDateTimeFormat(DateTime dateTime) {
|
||||
final now = DateTime.now();
|
||||
|
||||
final justNow = now.subtract(const Duration(minutes: 1));
|
||||
|
||||
final localDateTime = dateTime.toLocal();
|
||||
|
||||
if (!localDateTime.difference(justNow).isNegative) {
|
||||
return translate('date_formatter.just_now');
|
||||
}
|
||||
|
||||
final roughTimeString = DateFormat.jm().format(dateTime);
|
||||
|
||||
if (localDateTime.day == now.day &&
|
||||
localDateTime.month == now.month &&
|
||||
localDateTime.year == now.year) {
|
||||
return roughTimeString;
|
||||
}
|
||||
|
||||
final yesterday = now.subtract(const Duration(days: 1));
|
||||
|
||||
if (localDateTime.day == yesterday.day &&
|
||||
localDateTime.month == now.month &&
|
||||
localDateTime.year == now.year) {
|
||||
return translate('date_formatter.yesterday');
|
||||
}
|
||||
|
||||
if (now.difference(localDateTime).inDays < 4) {
|
||||
final weekday = DateFormat(DateFormat.WEEKDAY).format(localDateTime);
|
||||
|
||||
return '$weekday, $roughTimeString';
|
||||
}
|
||||
return '${DateFormat.yMd().format(dateTime)}, $roughTimeString';
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
|
||||
Utf8LengthLimitingTextInputFormatter({this.maxLength})
|
||||
: assert(maxLength != null || maxLength! >= 0, 'maxLength is invalid');
|
||||
|
||||
final int? maxLength;
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
if (maxLength != null && _bytesLength(newValue.text) > maxLength!) {
|
||||
// If already at the maximum and tried to enter even more,
|
||||
// keep the old value.
|
||||
if (_bytesLength(oldValue.text) == maxLength) {
|
||||
return oldValue;
|
||||
}
|
||||
return _truncate(newValue, maxLength!);
|
||||
}
|
||||
return newValue;
|
||||
}
|
||||
|
||||
static TextEditingValue _truncate(TextEditingValue value, int maxLength) {
|
||||
var newValue = '';
|
||||
if (_bytesLength(value.text) > maxLength) {
|
||||
var length = 0;
|
||||
|
||||
value.text.characters.takeWhile((char) {
|
||||
final nbBytes = _bytesLength(char);
|
||||
if (length + nbBytes <= maxLength) {
|
||||
newValue += char;
|
||||
length += nbBytes;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return TextEditingValue(
|
||||
text: newValue,
|
||||
selection: value.selection.copyWith(
|
||||
baseOffset: min(value.selection.start, newValue.length),
|
||||
extentOffset: min(value.selection.end, newValue.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static int _bytesLength(String value) => utf8.encode(value).length;
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
export 'chat_component_widget.dart';
|
||||
export 'empty_chat_widget.dart';
|
||||
export 'no_conversation_widget.dart';
|
||||
export 'utf8_length_limiting_text_input_formatter.dart';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue