new chat widget

This commit is contained in:
Christien Rioux 2025-05-17 18:02:17 -04:00
parent 063eeb8d12
commit 1a9cca0667
44 changed files with 1904 additions and 981 deletions

View file

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

View file

@ -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();
}

View file

@ -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 {
//
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,2 @@
export 'vc_composer_widget.dart';
export 'vc_text_message_widget.dart';

View 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();
}
}
}

View 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));
}
}

View file

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

View 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';
}
}

View file

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

View file

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