diff --git a/CHANGELOG.md b/CHANGELOG.md index 9182300..a7b1930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Fixed issue with Android 'back' button exiting the app (#331) - Deprecated accounts no longer crash application at startup - Simplify SingleContactMessagesCubit and MessageReconciliation +- Update flutter_chat_ui to 2.0.0 ## v0.4.7 ## - *Community Contributions* diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 9192851..f0b84a0 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -313,5 +313,9 @@ "info": "Info", "debug": "Debug", "trace": "Trace" + }, + "date_formatter": { + "just_now": "Just now", + "yesterday": "Yesterday" } } \ No newline at end of file diff --git a/flutter_01.png b/flutter_01.png new file mode 100644 index 0000000..e69de29 diff --git a/flutter_02.png b/flutter_02.png new file mode 100644 index 0000000..e69de29 diff --git a/flutter_03.png b/flutter_03.png new file mode 100644 index 0000000..e69de29 diff --git a/lib/account_manager/models/local_account/local_account.dart b/lib/account_manager/models/local_account/local_account.dart index 49506d0..81cfb8c 100644 --- a/lib/account_manager/models/local_account/local_account.dart +++ b/lib/account_manager/models/local_account/local_account.dart @@ -15,8 +15,9 @@ part 'local_account.freezed.dart'; // and the identitySecretKey optionally encrypted by an unlock code // This is the root of the account information tree for VeilidChat // -@freezed -sealed class LocalAccount with _$LocalAccount { +@Freezed(toJson: true) +abstract class LocalAccount with _$LocalAccount { + @JsonSerializable() const factory LocalAccount({ // The super identity key record for the account, // containing the publicKey in the currentIdentity diff --git a/lib/account_manager/models/local_account/local_account.freezed.dart b/lib/account_manager/models/local_account/local_account.freezed.dart index e2c3c55..8d7aed1 100644 --- a/lib/account_manager/models/local_account/local_account.freezed.dart +++ b/lib/account_manager/models/local_account/local_account.freezed.dart @@ -153,6 +153,7 @@ class _$LocalAccountCopyWithImpl<$Res> implements $LocalAccountCopyWith<$Res> { } /// @nodoc + @JsonSerializable() class _LocalAccount implements LocalAccount { const _LocalAccount( @@ -162,8 +163,6 @@ class _LocalAccount implements LocalAccount { required this.biometricsEnabled, required this.hiddenAccount, required this.name}); - factory _LocalAccount.fromJson(Map json) => - _$LocalAccountFromJson(json); // The super identity key record for the account, // containing the publicKey in the currentIdentity diff --git a/lib/account_manager/models/user_login/user_login.dart b/lib/account_manager/models/user_login/user_login.dart index 4d96d38..4e2f680 100644 --- a/lib/account_manager/models/user_login/user_login.dart +++ b/lib/account_manager/models/user_login/user_login.dart @@ -8,8 +8,9 @@ part 'user_login.g.dart'; // Represents a currently logged in account // User logins are stored in the user_logins tablestore table // indexed by the accountSuperIdentityRecordKey -@freezed +@Freezed(toJson: true) sealed class UserLogin with _$UserLogin { + @JsonSerializable() const factory UserLogin({ // SuperIdentity record key for the user // used to index the local accounts table diff --git a/lib/account_manager/models/user_login/user_login.freezed.dart b/lib/account_manager/models/user_login/user_login.freezed.dart index b0c6070..c406812 100644 --- a/lib/account_manager/models/user_login/user_login.freezed.dart +++ b/lib/account_manager/models/user_login/user_login.freezed.dart @@ -124,6 +124,7 @@ class _$UserLoginCopyWithImpl<$Res> implements $UserLoginCopyWith<$Res> { } /// @nodoc + @JsonSerializable() class _UserLogin implements UserLogin { const _UserLogin( @@ -131,8 +132,6 @@ class _UserLogin implements UserLogin { required this.identitySecret, required this.accountRecordInfo, required this.lastActive}); - factory _UserLogin.fromJson(Map json) => - _$UserLoginFromJson(json); // SuperIdentity record key for the user // used to index the local accounts table diff --git a/lib/account_manager/views/edit_account_page.dart b/lib/account_manager/views/edit_account_page.dart index bd18967..9af4719 100644 --- a/lib/account_manager/views/edit_account_page.dart +++ b/lib/account_manager/views/edit_account_page.dart @@ -249,7 +249,6 @@ class _EditAccountPageState extends WindowSetupState { final displayModalHUD = _isInAsyncCall; return StyledScaffold( - // resizeToAvoidBottomInset: false, appBar: DefaultAppBar( title: Text(translate('edit_account_page.titlebar')), leading: Navigator.canPop(context) diff --git a/lib/account_manager/views/show_recovery_key_page.dart b/lib/account_manager/views/show_recovery_key_page.dart index bf44dd7..7c971e0 100644 --- a/lib/account_manager/views/show_recovery_key_page.dart +++ b/lib/account_manager/views/show_recovery_key_page.dart @@ -163,7 +163,6 @@ class _ShowRecoveryKeyPageState extends WindowSetupState { final displayModalHUD = _isInAsyncCall; return StyledScaffold( - // resizeToAvoidBottomInset: false, appBar: DefaultAppBar( title: Text(translate('show_recovery_key_page.titlebar')), actions: [ diff --git a/lib/chat/cubits/chat_component_cubit.dart b/lib/chat/cubits/chat_component_cubit.dart index 6112384..76ff660 100644 --- a/lib/chat/cubits/chat_component_cubit.dart +++ b/lib/chat/cubits/chat_component_cubit.dart @@ -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 { _contactListCubit = contactListCubit, _conversationCubits = conversationCubits, _messagesCubit = messagesCubit, - super(ChatComponentState( - chatKey: GlobalKey(), - 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 { 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 { } // 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? attachments}) { + final replyId = (replyToMessageId != null) + ? base64UrlNoPadDecode(replyToMessageId) : null; - Timestamp? expiration; - int? viewLimit; - List? 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?; - if (attachmentsValue != null) { - attachments = attachmentsValue; - } - } _addTextMessage( text: text, @@ -172,9 +151,9 @@ class ChatComponentCubit extends Cubit { 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 { // 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 { } if (currentState.remoteUsers.length == 1) { final remoteUser = currentState.remoteUsers.values.first; - return currentState.copyWith(title: remoteUser.firstName ?? ''); + return currentState.copyWith(title: remoteUser.name ?? ''); } return currentState.copyWith( title: ''); } - 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 { 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 ?? '', 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 _updateConversationSubscriptions() async { // Get existing subscription keys and state @@ -267,16 +246,17 @@ class ChatComponentCubit extends Cubit { 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 = >[]; 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 { 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 { final messagesState = avMessagesState.asData!.value; // Convert protobuf messages to chat messages - final chatMessages = []; + final chatMessages = []; final tsSet = {}; for (final message in messagesState.window) { final (newState, chatMessage) = @@ -390,11 +383,11 @@ class ChatComponentCubit extends Cubit { // '\nChatMessages:\n$chatMessages' ); } else { - chatMessages.insert(0, chatMessage); + chatMessages.add(chatMessage); } } return currentState.copyWith( - messageWindow: AsyncValue.data(WindowState( + messageWindow: AsyncValue.data(WindowState( window: chatMessages.toIList(), length: messagesState.length, windowTail: messagesState.windowTail, diff --git a/lib/chat/cubits/single_contact_messages_cubit.dart b/lib/chat/cubits/single_contact_messages_cubit.dart index 66032f6..d93c4be 100644 --- a/lib/chat/cubits/single_contact_messages_cubit.dart +++ b/lib/chat/cubits/single_contact_messages_cubit.dart @@ -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 { Future close() async { await _initWait(); + await serialFutureClose((this, _sfSendMessageTag)); + await _commandController.close(); await _commandRunnerFut; await _unsentMessagesQueue.close(); @@ -309,9 +317,6 @@ class SingleContactMessagesCubit extends Cubit { // Async process to send messages in the background Future _processUnsentMessages(IList 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 { } 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 { // 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 { // values: unsentMessages, // ); + // List of all rendered state elements that we will turn into + // message states final renderedElements = []; + + // 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 = {}; + + 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 { 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 { 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 _commandRunner() async { @@ -487,7 +519,6 @@ class SingleContactMessagesCubit extends Cubit { late final MessageReconciliation _reconciliation; late final PersistentQueue _unsentMessagesQueue; - // IList _sendingMessages = const IList.empty(); StreamSubscription? _sentSubscription; StreamSubscription? _rcvdSubscription; StreamSubscription>? @@ -496,4 +527,5 @@ class SingleContactMessagesCubit extends Cubit { late final Future _commandRunnerFut; final _sspRemoteConversationRecordKey = SingleStateProcessor(); + final _uuidGen = const UuidV4(); } diff --git a/lib/chat/models/chat_component_state.dart b/lib/chat/models/chat_component_state.dart index 82e492d..1a52524 100644 --- a/lib/chat/models/chat_component_state.dart +++ b/lib/chat/models/chat_component_state.dart @@ -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 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 remoteUsers, + required IMap remoteUsers, // Historical remote users - required IMap historicalRemoteUsers, + required IMap historicalRemoteUsers, // Unknown users - required IMap unknownUsers, + required IMap unknownUsers, // Messages state required AsyncValue> messageWindow, // Title of the chat required String title}) = _ChatComponentState; } - -extension ChatComponentStateExt on ChatComponentState { - // -} diff --git a/lib/chat/models/chat_component_state.freezed.dart b/lib/chat/models/chat_component_state.freezed.dart index ae3acee..dd5e68e 100644 --- a/lib/chat/models/chat_component_state.freezed.dart +++ b/lib/chat/models/chat_component_state.freezed.dart @@ -15,15 +15,11 @@ T _$identity(T value) => value; /// @nodoc mixin _$ChatComponentState { -// GlobalKey for the chat - GlobalKey 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 get remoteUsers; // Historical remote users - IMap get historicalRemoteUsers; // Unknown users - IMap get unknownUsers; // Messages state + IMap get remoteUsers; // Historical remote users + IMap get historicalRemoteUsers; // Unknown users + IMap get unknownUsers; // Messages state AsyncValue> 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 chatKey, - AutoScrollController scrollController, - InputTextFieldController textEditingController, - User? localUser, - IMap, User> remoteUsers, - IMap, User> historicalRemoteUsers, - IMap, User> unknownUsers, + {User? localUser, + IMap remoteUsers, + IMap historicalRemoteUsers, + IMap unknownUsers, AsyncValue> messageWindow, String title}); + $UserCopyWith<$Res>? get localUser; $AsyncValueCopyWith, $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, - 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, User>, + as IMap, historicalRemoteUsers: null == historicalRemoteUsers ? _self.historicalRemoteUsers! : historicalRemoteUsers // ignore: cast_nullable_to_non_nullable - as IMap, User>, + as IMap, unknownUsers: null == unknownUsers ? _self.unknownUsers! : unknownUsers // ignore: cast_nullable_to_non_nullable - as IMap, User>, + as IMap, 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 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, User> remoteUsers; + final IMap remoteUsers; // Historical remote users @override - final IMap, User> historicalRemoteUsers; + final IMap historicalRemoteUsers; // Unknown users @override - final IMap, User> unknownUsers; + final IMap unknownUsers; // Messages state @override final AsyncValue> 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 chatKey, - AutoScrollController scrollController, - InputTextFieldController textEditingController, - User? localUser, - IMap, User> remoteUsers, - IMap, User> historicalRemoteUsers, - IMap, User> unknownUsers, + {User? localUser, + IMap remoteUsers, + IMap historicalRemoteUsers, + IMap unknownUsers, AsyncValue> messageWindow, String title}); + @override + $UserCopyWith<$Res>? get localUser; @override $AsyncValueCopyWith, $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, - 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, User>, + as IMap, historicalRemoteUsers: null == historicalRemoteUsers ? _self.historicalRemoteUsers : historicalRemoteUsers // ignore: cast_nullable_to_non_nullable - as IMap, User>, + as IMap, unknownUsers: null == unknownUsers ? _self.unknownUsers : unknownUsers // ignore: cast_nullable_to_non_nullable - as IMap, User>, + as IMap, 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 diff --git a/lib/chat/models/message_state.dart b/lib/chat/models/message_state.dart index cf82021..80852e6 100644 --- a/lib/chat/models/message_state.dart +++ b/lib/chat/models/message_state.dart @@ -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, diff --git a/lib/chat/models/message_state.freezed.dart b/lib/chat/models/message_state.freezed.dart index 0900f8b..342b305 100644 --- a/lib/chat/models/message_state.freezed.dart +++ b/lib/chat/models/message_state.freezed.dart @@ -15,7 +15,8 @@ T _$identity(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 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 diff --git a/lib/chat/models/message_state.g.dart b/lib/chat/models/message_state.g.dart index daae37f..2eee78d 100644 --- a/lib/chat/models/message_state.g.dart +++ b/lib/chat/models/message_state.g.dart @@ -8,6 +8,7 @@ part of 'message_state.dart'; _MessageState _$MessageStateFromJson(Map json) => _MessageState( + seqId: (json['seq_id'] as num).toInt(), content: messageFromJson(json['content'] as Map), sentTimestamp: Timestamp.fromJson(json['sent_timestamp']), reconciledTimestamp: json['reconciled_timestamp'] == null @@ -20,6 +21,7 @@ _MessageState _$MessageStateFromJson(Map json) => Map _$MessageStateToJson(_MessageState instance) => { + 'seq_id': instance.seqId, 'content': messageToJson(instance.content), 'sent_timestamp': instance.sentTimestamp.toJson(), 'reconciled_timestamp': instance.reconciledTimestamp?.toJson(), diff --git a/lib/chat/views/chat_builders/chat_builders.dart b/lib/chat/views/chat_builders/chat_builders.dart new file mode 100644 index 0000000..529341f --- /dev/null +++ b/lib/chat/views/chat_builders/chat_builders.dart @@ -0,0 +1,2 @@ +export 'vc_composer_widget.dart'; +export 'vc_text_message_widget.dart'; diff --git a/lib/chat/views/chat_builders/vc_composer_widget.dart b/lib/chat/views/chat_builders/vc_composer_widget.dart new file mode 100644 index 0000000..b3eb1e5 --- /dev/null +++ b/lib/chat/views/chat_builders/vc_composer_widget.dart @@ -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 createState() => _VcComposerState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty( + '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('padding', padding)) + ..add(DoubleProperty('gap', gap)) + ..add(DiagnosticsProperty('inputBorder', inputBorder)) + ..add(DiagnosticsProperty('filled', filled)) + ..add(DiagnosticsProperty('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('keyboardAppearance', keyboardAppearance)) + ..add(DiagnosticsProperty('autocorrect', autocorrect)) + ..add(DiagnosticsProperty('autofocus', autofocus)) + ..add(EnumProperty( + 'textCapitalization', textCapitalization)) + ..add(DiagnosticsProperty('keyboardType', keyboardType)) + ..add(EnumProperty('textInputAction', textInputAction)) + ..add( + EnumProperty('shiftEnterAction', shiftEnterAction)) + ..add(DiagnosticsProperty('focusNode', focusNode)) + ..add(IntProperty('maxLength', maxLength)) + ..add(IntProperty('minLines', minLines)) + ..add(IntProperty('maxLines', maxLines)); + } +} + +class _VcComposerState extends State { + 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(); + final theme = Theme.of(context); + final scaleTheme = theme.extension()!; + 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().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()?.call(text); + _textController.clear(); + } + } +} diff --git a/lib/chat/views/chat_builders/vc_text_message_widget.dart b/lib/chat/views/chat_builders/vc_text_message_widget.dart new file mode 100644 index 0000000..fc1fe80 --- /dev/null +++ b/lib/chat/views/chat_builders/vc_text_message_widget.dart @@ -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()!; + 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() == 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('message', message)) + ..add(IntProperty('index', index)) + ..add(DiagnosticsProperty('padding', padding)) + ..add(DiagnosticsProperty( + 'borderRadius', borderRadius)) + ..add(DoubleProperty('onlyEmojiFontSize', onlyEmojiFontSize)) + ..add(ColorProperty('sentBackgroundColor', sentBackgroundColor)) + ..add(ColorProperty('receivedBackgroundColor', receivedBackgroundColor)) + ..add(DiagnosticsProperty('sentTextStyle', sentTextStyle)) + ..add(DiagnosticsProperty( + 'receivedTextStyle', receivedTextStyle)) + ..add(DiagnosticsProperty('timeStyle', timeStyle)) + ..add(DiagnosticsProperty('showTime', showTime)) + ..add(DiagnosticsProperty('showStatus', showStatus)) + ..add(EnumProperty( + '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('time', time)) + ..add(EnumProperty('status', status)) + ..add(DiagnosticsProperty('showTime', showTime)) + ..add(DiagnosticsProperty('showStatus', showStatus)) + ..add(DiagnosticsProperty('textStyle', textStyle)); + } +} diff --git a/lib/chat/views/chat_component_widget.dart b/lib/chat/views/chat_component_widget.dart index 1646772..aed7356 100644 --- a/lib/chat/views/chat_component_widget.dart +++ b/lib/chat/views/chat_component_widget.dart @@ -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().state; @@ -45,19 +55,19 @@ class ChatComponentWidget extends StatelessWidget { // Get the active conversation cubit final activeConversationCubit = context .select( - (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( - (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 createState() => _ChatComponentWidgetState(); - Widget _buildChatComponent(BuildContext context) { + //////////////////////////////////////////////////////////////////////////// + final TypedKey _localConversationRecordKey; + final void Function() _onCancel; + final void Function() _onClose; +} + +class _ChatComponentWidgetState extends State { + //////////////////////////////////////////////////////////////////// + + @override + void initState() { + _chatController = core.InMemoryChatController(); + _textEditingController = TextEditingController(); + _scrollController = ScrollController(); + _chatStateProcessor = SingleStateProcessor(); + + final _chatComponentCubit = context.read(); + _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()!; - final scaleConfig = theme.extension()!; - final scale = scaleScheme.scale(ScaleKind.primary); + final scaleTheme = theme.extension()!; + 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().error( - text: translate('chat.message_too_long')); - return; - } - _handleSendPressed(chatComponentCubit, pt); - } on FormatException { - context.read().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 _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() + .error(text: translate('chat.message_too_long')); + return; + } + + chatComponentCubit.sendMessage(text: text); } // void _handleAttachmentPressed() async { - // // - // } - Future _handlePageForward( ChatComponentCubit chatComponentCubit, - WindowState messageWindow, + WindowState messageWindow, ScrollNotification notification) async { debugPrint( '_handlePageForward: messagesState.length=${messageWindow.length} ' @@ -299,7 +446,7 @@ class ChatComponentWidget extends StatelessWidget { Future _handlePageBackward( ChatComponentCubit chatComponentCubit, - WindowState messageWindow, + WindowState 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 _chatStateProcessor; } diff --git a/lib/chat/views/date_formatter.dart b/lib/chat/views/date_formatter.dart new file mode 100644 index 0000000..2835b70 --- /dev/null +++ b/lib/chat/views/date_formatter.dart @@ -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'; + } +} diff --git a/lib/chat/views/utf8_length_limiting_text_input_formatter.dart b/lib/chat/views/utf8_length_limiting_text_input_formatter.dart new file mode 100644 index 0000000..f037ca8 --- /dev/null +++ b/lib/chat/views/utf8_length_limiting_text_input_formatter.dart @@ -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; +} diff --git a/lib/chat/views/views.dart b/lib/chat/views/views.dart index 9703643..41b1936 100644 --- a/lib/chat/views/views.dart +++ b/lib/chat/views/views.dart @@ -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'; diff --git a/lib/init.dart b/lib/init.dart index 8c80bf9..958cd08 100644 --- a/lib/init.dart +++ b/lib/init.dart @@ -17,8 +17,12 @@ class VeilidChatGlobalInit { // Initialize Veilid Future _initializeVeilid() async { // Init Veilid - Veilid.instance.initializeVeilidCore( - await getDefaultVeilidPlatformConfig(kIsWeb, VeilidChatApp.name)); + try { + Veilid.instance.initializeVeilidCore( + await getDefaultVeilidPlatformConfig(kIsWeb, VeilidChatApp.name)); + } on VeilidAPIExceptionAlreadyInitialized { + log.debug('Already initialized, not reinitializing veilid-core'); + } // Veilid logging initVeilidLog(kIsDebugMode); diff --git a/lib/keyboard_shortcuts.dart b/lib/keyboard_shortcuts.dart index 0c531a9..1e25dac 100644 --- a/lib/keyboard_shortcuts.dart +++ b/lib/keyboard_shortcuts.dart @@ -16,6 +16,14 @@ class ReloadThemeIntent extends Intent { const ReloadThemeIntent(); } +class ChangeBrightnessIntent extends Intent { + const ChangeBrightnessIntent(); +} + +class ChangeColorIntent extends Intent { + const ChangeColorIntent(); +} + class AttachDetachIntent extends Intent { const AttachDetachIntent(); } @@ -49,6 +57,49 @@ class KeyboardShortcuts extends StatelessWidget { }); } + void changeBrightness(BuildContext context) { + singleFuture(this, () async { + final prefs = PreferencesRepository.instance.value; + + final oldBrightness = prefs.themePreference.brightnessPreference; + final newBrightness = BrightnessPreference.values[ + (oldBrightness.index + 1) % BrightnessPreference.values.length]; + + log.info('Changing brightness to $newBrightness'); + + final newPrefs = prefs.copyWith( + themePreference: prefs.themePreference + .copyWith(brightnessPreference: newBrightness)); + await PreferencesRepository.instance.set(newPrefs); + + if (context.mounted) { + ThemeSwitcher.of(context) + .changeTheme(theme: newPrefs.themePreference.themeData()); + } + }); + } + + void changeColor(BuildContext context) { + singleFuture(this, () async { + final prefs = PreferencesRepository.instance.value; + final oldColor = prefs.themePreference.colorPreference; + final newColor = ColorPreference + .values[(oldColor.index + 1) % ColorPreference.values.length]; + + log.info('Changing color to $newColor'); + + final newPrefs = prefs.copyWith( + themePreference: + prefs.themePreference.copyWith(colorPreference: newColor)); + await PreferencesRepository.instance.set(newPrefs); + + if (context.mounted) { + ThemeSwitcher.of(context) + .changeTheme(theme: newPrefs.themePreference.themeData()); + } + }); + } + void _attachDetach(BuildContext context) { singleFuture(this, () async { if (ProcessorRepository.instance.processorConnectionState.isAttached) { @@ -75,17 +126,34 @@ class KeyboardShortcuts extends StatelessWidget { Widget build(BuildContext context) => ThemeSwitcher( builder: (context) => Shortcuts( shortcuts: { - LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.keyR): - const ReloadThemeIntent(), - LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.keyD): - const AttachDetachIntent(), LogicalKeySet( - LogicalKeyboardKey.alt, LogicalKeyboardKey.backquote): - const DeveloperPageIntent(), + LogicalKeyboardKey.alt, + LogicalKeyboardKey.control, + LogicalKeyboardKey.keyR): const ReloadThemeIntent(), + LogicalKeySet( + LogicalKeyboardKey.alt, + LogicalKeyboardKey.control, + LogicalKeyboardKey.keyB): const ChangeBrightnessIntent(), + LogicalKeySet( + LogicalKeyboardKey.alt, + LogicalKeyboardKey.control, + LogicalKeyboardKey.keyC): const ChangeColorIntent(), + LogicalKeySet( + LogicalKeyboardKey.alt, + LogicalKeyboardKey.control, + LogicalKeyboardKey.keyD): const AttachDetachIntent(), + LogicalKeySet( + LogicalKeyboardKey.alt, + LogicalKeyboardKey.control, + LogicalKeyboardKey.backquote): const DeveloperPageIntent(), }, child: Actions(actions: >{ ReloadThemeIntent: CallbackAction( onInvoke: (intent) => reloadTheme(context)), + ChangeBrightnessIntent: CallbackAction( + onInvoke: (intent) => changeBrightness(context)), + ChangeColorIntent: CallbackAction( + onInvoke: (intent) => changeColor(context)), AttachDetachIntent: CallbackAction( onInvoke: (intent) => _attachDetach(context)), DeveloperPageIntent: CallbackAction( diff --git a/lib/layout/home/home_account_ready.dart b/lib/layout/home/home_account_ready.dart index 3674a08..a2966a6 100644 --- a/lib/layout/home/home_account_ready.dart +++ b/lib/layout/home/home_account_ready.dart @@ -130,15 +130,19 @@ class _HomeAccountReadyState extends State { if (activeChatLocalConversationKey == null) { return const NoConversationWidget(); } - return ChatComponentWidget( - localConversationRecordKey: activeChatLocalConversationKey, - onCancel: () { - activeChatCubit.setActiveChat(null); - }, - onClose: () { - activeChatCubit.setActiveChat(null); - }, - key: ValueKey(activeChatLocalConversationKey)); + return Material( + color: Colors.transparent, + child: Builder( + builder: (context) => ChatComponentWidget.singleContact( + context: context, + localConversationRecordKey: activeChatLocalConversationKey, + onCancel: () { + activeChatCubit.setActiveChat(null); + }, + onClose: () { + activeChatCubit.setActiveChat(null); + }, + key: ValueKey(activeChatLocalConversationKey)))); } @override diff --git a/lib/layout/home/home_screen.dart b/lib/layout/home/home_screen.dart index 3d1b14f..b4c3b58 100644 --- a/lib/layout/home/home_screen.dart +++ b/lib/layout/home/home_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:flutter_zoom_drawer/flutter_zoom_drawer.dart'; +import 'package:keyboard_avoider/keyboard_avoider.dart'; import 'package:provider/provider.dart'; import 'package:transitioned_indexed_stack/transitioned_indexed_stack.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -207,33 +208,32 @@ class HomeScreenState extends State return DefaultTextStyle( style: theme.textTheme.bodySmall!, - child: ZoomDrawer( - controller: _zoomDrawerController, - menuScreen: Builder(builder: (context) { - final zoomDrawer = ZoomDrawer.of(context); - zoomDrawer!.stateNotifier.addListener(() { - if (zoomDrawer.isOpen()) { - FocusManager.instance.primaryFocus?.unfocus(); - } - }); - return const DrawerMenu(); - }), - mainScreen: Provider.value( - value: _zoomDrawerController, - child: Builder(builder: _buildAccountPageView)), - borderRadius: 0, - angle: 0, - //mainScreenOverlayColor: theme.shadowColor.withAlpha(0x2F), - openCurve: Curves.fastEaseInToSlowEaseOut, - closeCurve: Curves.fastEaseInToSlowEaseOut, - // duration: const Duration(milliseconds: 250), - // reverseDuration: const Duration(milliseconds: 250), - menuScreenTapClose: canClose, - mainScreenTapClose: canClose, - disableDragGesture: !canClose, - mainScreenScale: .25, - slideWidth: min(360, MediaQuery.of(context).size.width * 0.9), - )); + child: KeyboardAvoider( + curve: Curves.ease, + child: ZoomDrawer( + controller: _zoomDrawerController, + menuScreen: Builder(builder: (context) { + final zoomDrawer = ZoomDrawer.of(context); + zoomDrawer!.stateNotifier.addListener(() { + if (zoomDrawer.isOpen()) { + FocusManager.instance.primaryFocus?.unfocus(); + } + }); + return const DrawerMenu(); + }), + mainScreen: Provider.value( + value: _zoomDrawerController, + child: Builder(builder: _buildAccountPageView)), + borderRadius: 0, + angle: 0, + openCurve: Curves.fastEaseInToSlowEaseOut, + closeCurve: Curves.fastEaseInToSlowEaseOut, + menuScreenTapClose: canClose, + mainScreenTapClose: canClose, + disableDragGesture: !canClose, + mainScreenScale: .25, + slideWidth: min(360, MediaQuery.of(context).size.width * 0.9), + ))); } //////////////////////////////////////////////////////////////////////////// diff --git a/lib/theme/models/chat_theme.dart b/lib/theme/models/chat_theme.dart deleted file mode 100644 index b650e17..0000000 --- a/lib/theme/models/chat_theme.dart +++ /dev/null @@ -1,488 +0,0 @@ -// ignore_for_file: always_put_required_named_parameters_first - -import 'package:flutter/material.dart'; -import 'package:flutter_chat_ui/flutter_chat_ui.dart'; - -import 'scale_theme/scale_scheme.dart'; - -ChatTheme makeChatTheme( - ScaleScheme scale, ScaleConfig scaleConfig, TextTheme textTheme) => - DefaultChatTheme( - primaryColor: scaleConfig.preferBorders - ? scale.primaryScale.calloutText - : scale.primaryScale.calloutBackground, - secondaryColor: scaleConfig.preferBorders - ? scale.secondaryScale.calloutText - : scale.secondaryScale.calloutBackground, - backgroundColor: - scale.grayScale.appBackground.withAlpha(scaleConfig.wallpaperAlpha), - messageBorderRadius: scaleConfig.borderRadiusScale * 12, - bubbleBorderSide: scaleConfig.preferBorders - ? BorderSide( - color: scale.primaryScale.calloutBackground, - width: 2, - ) - : BorderSide(width: 2, color: Colors.black.withAlpha(96)), - sendButtonIcon: Image.asset( - 'assets/icon-send.png', - color: scaleConfig.preferBorders - ? scale.primaryScale.border - : scale.primaryScale.borderText, - package: 'flutter_chat_ui', - ), - inputBackgroundColor: Colors.blue, - inputBorderRadius: BorderRadius.zero, - inputTextStyle: textTheme.bodyLarge!, - inputTextDecoration: InputDecoration( - filled: !scaleConfig.preferBorders, - fillColor: scale.primaryScale.subtleBackground, - isDense: true, - contentPadding: const EdgeInsets.fromLTRB(8, 8, 8, 8), - disabledBorder: OutlineInputBorder( - borderSide: scaleConfig.preferBorders - ? BorderSide(color: scale.grayScale.border, width: 2) - : BorderSide.none, - borderRadius: BorderRadius.all( - Radius.circular(8 * scaleConfig.borderRadiusScale))), - enabledBorder: OutlineInputBorder( - borderSide: scaleConfig.preferBorders - ? BorderSide(color: scale.primaryScale.border, width: 2) - : BorderSide.none, - borderRadius: BorderRadius.all( - Radius.circular(8 * scaleConfig.borderRadiusScale))), - focusedBorder: OutlineInputBorder( - borderSide: scaleConfig.preferBorders - ? BorderSide(color: scale.primaryScale.border, width: 2) - : BorderSide.none, - borderRadius: BorderRadius.all( - Radius.circular(8 * scaleConfig.borderRadiusScale))), - ), - inputContainerDecoration: BoxDecoration( - border: scaleConfig.preferBorders - ? Border( - top: BorderSide(color: scale.primaryScale.border, width: 2)) - : null, - color: scaleConfig.preferBorders - ? scale.primaryScale.elementBackground - : scale.primaryScale.border), - inputPadding: const EdgeInsets.all(6), - inputTextColor: !scaleConfig.preferBorders - ? scale.primaryScale.appText - : scale.primaryScale.border, - messageInsetsHorizontal: 12, - messageInsetsVertical: 8, - attachmentButtonIcon: const Icon(Icons.attach_file), - sentMessageBodyTextStyle: textTheme.bodyLarge!.copyWith( - color: scaleConfig.preferBorders - ? scale.primaryScale.calloutBackground - : scale.primaryScale.calloutText, - ), - sentEmojiMessageTextStyle: const TextStyle( - color: Colors.white, - fontSize: 64, - ), - receivedMessageBodyTextStyle: textTheme.bodyLarge!.copyWith( - color: scaleConfig.preferBorders - ? scale.secondaryScale.calloutBackground - : scale.secondaryScale.calloutText, - ), - receivedEmojiMessageTextStyle: const TextStyle( - color: Colors.white, - fontSize: 64, - ), - dateDividerTextStyle: textTheme.labelSmall!); - -class EditedChatTheme extends ChatTheme { - const EditedChatTheme({ - required super.attachmentButtonIcon, - required super.attachmentButtonMargin, - required super.backgroundColor, - super.bubbleMargin, - required super.dateDividerMargin, - required super.dateDividerTextStyle, - required super.deliveredIcon, - required super.documentIcon, - required super.emptyChatPlaceholderTextStyle, - required super.errorColor, - required super.errorIcon, - required super.inputBackgroundColor, - required super.inputSurfaceTintColor, - required super.inputElevation, - required super.inputBorderRadius, - super.inputContainerDecoration, - required super.inputMargin, - required super.inputPadding, - required super.inputTextColor, - super.inputTextCursorColor, - required super.inputTextDecoration, - required super.inputTextStyle, - required super.messageBorderRadius, - required super.messageInsetsHorizontal, - required super.messageInsetsVertical, - required super.messageMaxWidth, - required super.primaryColor, - required super.receivedEmojiMessageTextStyle, - super.receivedMessageBodyBoldTextStyle, - super.receivedMessageBodyCodeTextStyle, - super.receivedMessageBodyLinkTextStyle, - required super.receivedMessageBodyTextStyle, - required super.receivedMessageCaptionTextStyle, - required super.receivedMessageDocumentIconColor, - required super.receivedMessageLinkDescriptionTextStyle, - required super.receivedMessageLinkTitleTextStyle, - required super.secondaryColor, - required super.seenIcon, - required super.sendButtonIcon, - required super.sendButtonMargin, - required super.sendingIcon, - required super.sentEmojiMessageTextStyle, - super.sentMessageBodyBoldTextStyle, - super.sentMessageBodyCodeTextStyle, - super.sentMessageBodyLinkTextStyle, - required super.sentMessageBodyTextStyle, - required super.sentMessageCaptionTextStyle, - required super.sentMessageDocumentIconColor, - required super.sentMessageLinkDescriptionTextStyle, - required super.sentMessageLinkTitleTextStyle, - required super.statusIconPadding, - required super.systemMessageTheme, - required super.typingIndicatorTheme, - required super.unreadHeaderTheme, - required super.userAvatarImageBackgroundColor, - required super.userAvatarNameColors, - required super.userAvatarTextStyle, - required super.userNameTextStyle, - super.highlightMessageColor, - }); -} - -class ChatThemeEditor { - ChatThemeEditor(ChatTheme base) - : attachmentButtonIcon = base.attachmentButtonIcon, - attachmentButtonMargin = base.attachmentButtonMargin, - backgroundColor = base.backgroundColor, - bubbleMargin = base.bubbleMargin, - dateDividerMargin = base.dateDividerMargin, - dateDividerTextStyle = base.dateDividerTextStyle, - deliveredIcon = base.deliveredIcon, - documentIcon = base.documentIcon, - emptyChatPlaceholderTextStyle = base.emptyChatPlaceholderTextStyle, - errorColor = base.errorColor, - errorIcon = base.errorIcon, - inputBackgroundColor = base.inputBackgroundColor, - inputSurfaceTintColor = base.inputSurfaceTintColor, - inputElevation = base.inputElevation, - inputBorderRadius = base.inputBorderRadius, - inputContainerDecoration = base.inputContainerDecoration, - inputMargin = base.inputMargin, - inputPadding = base.inputPadding, - inputTextColor = base.inputTextColor, - inputTextCursorColor = base.inputTextCursorColor, - inputTextDecoration = base.inputTextDecoration, - inputTextStyle = base.inputTextStyle, - messageBorderRadius = base.messageBorderRadius, - messageInsetsHorizontal = base.messageInsetsHorizontal, - messageInsetsVertical = base.messageInsetsVertical, - messageMaxWidth = base.messageMaxWidth, - primaryColor = base.primaryColor, - receivedEmojiMessageTextStyle = base.receivedEmojiMessageTextStyle, - receivedMessageBodyBoldTextStyle = - base.receivedMessageBodyBoldTextStyle, - receivedMessageBodyCodeTextStyle = - base.receivedMessageBodyCodeTextStyle, - receivedMessageBodyLinkTextStyle = - base.receivedMessageBodyLinkTextStyle, - receivedMessageBodyTextStyle = base.receivedMessageBodyTextStyle, - receivedMessageCaptionTextStyle = base.receivedMessageCaptionTextStyle, - receivedMessageDocumentIconColor = - base.receivedMessageDocumentIconColor, - receivedMessageLinkDescriptionTextStyle = - base.receivedMessageLinkDescriptionTextStyle, - receivedMessageLinkTitleTextStyle = - base.receivedMessageLinkTitleTextStyle, - secondaryColor = base.secondaryColor, - seenIcon = base.seenIcon, - sendButtonIcon = base.sendButtonIcon, - sendButtonMargin = base.sendButtonMargin, - sendingIcon = base.sendingIcon, - sentEmojiMessageTextStyle = base.sentEmojiMessageTextStyle, - sentMessageBodyBoldTextStyle = base.sentMessageBodyBoldTextStyle, - sentMessageBodyCodeTextStyle = base.sentMessageBodyCodeTextStyle, - sentMessageBodyLinkTextStyle = base.sentMessageBodyLinkTextStyle, - sentMessageBodyTextStyle = base.sentMessageBodyTextStyle, - sentMessageCaptionTextStyle = base.sentMessageCaptionTextStyle, - sentMessageDocumentIconColor = base.sentMessageDocumentIconColor, - sentMessageLinkDescriptionTextStyle = - base.sentMessageLinkDescriptionTextStyle, - sentMessageLinkTitleTextStyle = base.sentMessageLinkTitleTextStyle, - statusIconPadding = base.statusIconPadding, - systemMessageTheme = base.systemMessageTheme, - typingIndicatorTheme = base.typingIndicatorTheme, - unreadHeaderTheme = base.unreadHeaderTheme, - userAvatarImageBackgroundColor = base.userAvatarImageBackgroundColor, - userAvatarNameColors = base.userAvatarNameColors, - userAvatarTextStyle = base.userAvatarTextStyle, - userNameTextStyle = base.userNameTextStyle, - highlightMessageColor = base.highlightMessageColor; - - EditedChatTheme commit() => EditedChatTheme( - attachmentButtonIcon: attachmentButtonIcon, - attachmentButtonMargin: attachmentButtonMargin, - backgroundColor: backgroundColor, - bubbleMargin: bubbleMargin, - dateDividerMargin: dateDividerMargin, - dateDividerTextStyle: dateDividerTextStyle, - deliveredIcon: deliveredIcon, - documentIcon: documentIcon, - emptyChatPlaceholderTextStyle: emptyChatPlaceholderTextStyle, - errorColor: errorColor, - errorIcon: errorIcon, - inputBackgroundColor: inputBackgroundColor, - inputSurfaceTintColor: inputSurfaceTintColor, - inputElevation: inputElevation, - inputBorderRadius: inputBorderRadius, - inputContainerDecoration: inputContainerDecoration, - inputMargin: inputMargin, - inputPadding: inputPadding, - inputTextColor: inputTextColor, - inputTextCursorColor: inputTextCursorColor, - inputTextDecoration: inputTextDecoration, - inputTextStyle: inputTextStyle, - messageBorderRadius: messageBorderRadius, - messageInsetsHorizontal: messageInsetsHorizontal, - messageInsetsVertical: messageInsetsVertical, - messageMaxWidth: messageMaxWidth, - primaryColor: primaryColor, - receivedEmojiMessageTextStyle: receivedEmojiMessageTextStyle, - receivedMessageBodyBoldTextStyle: receivedMessageBodyBoldTextStyle, - receivedMessageBodyCodeTextStyle: receivedMessageBodyCodeTextStyle, - receivedMessageBodyLinkTextStyle: receivedMessageBodyLinkTextStyle, - receivedMessageBodyTextStyle: receivedMessageBodyTextStyle, - receivedMessageCaptionTextStyle: receivedMessageCaptionTextStyle, - receivedMessageDocumentIconColor: receivedMessageDocumentIconColor, - receivedMessageLinkDescriptionTextStyle: - receivedMessageLinkDescriptionTextStyle, - receivedMessageLinkTitleTextStyle: receivedMessageLinkTitleTextStyle, - secondaryColor: secondaryColor, - seenIcon: seenIcon, - sendButtonIcon: sendButtonIcon, - sendButtonMargin: sendButtonMargin, - sendingIcon: sendingIcon, - sentEmojiMessageTextStyle: sentEmojiMessageTextStyle, - sentMessageBodyBoldTextStyle: sentMessageBodyBoldTextStyle, - sentMessageBodyCodeTextStyle: sentMessageBodyCodeTextStyle, - sentMessageBodyLinkTextStyle: sentMessageBodyLinkTextStyle, - sentMessageBodyTextStyle: sentMessageBodyTextStyle, - sentMessageCaptionTextStyle: sentMessageCaptionTextStyle, - sentMessageDocumentIconColor: sentMessageDocumentIconColor, - sentMessageLinkDescriptionTextStyle: - sentMessageLinkDescriptionTextStyle, - sentMessageLinkTitleTextStyle: sentMessageLinkTitleTextStyle, - statusIconPadding: statusIconPadding, - systemMessageTheme: systemMessageTheme, - typingIndicatorTheme: typingIndicatorTheme, - unreadHeaderTheme: unreadHeaderTheme, - userAvatarImageBackgroundColor: userAvatarImageBackgroundColor, - userAvatarNameColors: userAvatarNameColors, - userAvatarTextStyle: userAvatarTextStyle, - userNameTextStyle: userNameTextStyle, - highlightMessageColor: highlightMessageColor, - ); - - ///////////////////////////////////////////////////////////////////////////// - - /// Icon for select attachment button. - Widget? attachmentButtonIcon; - - /// Margin of attachment button. - EdgeInsets? attachmentButtonMargin; - - /// Used as a background color of a chat widget. - Color backgroundColor; - - // Margin around the message bubble. - EdgeInsetsGeometry? bubbleMargin; - - /// Margin around date dividers. - EdgeInsets dateDividerMargin; - - /// Text style of the date dividers. - TextStyle dateDividerTextStyle; - - /// Icon for message's `delivered` status. For the best look use size of 16. - Widget? deliveredIcon; - - /// Icon inside file message. - Widget? documentIcon; - - /// Text style of the empty chat placeholder. - TextStyle emptyChatPlaceholderTextStyle; - - /// Color to indicate something bad happened (usually - shades of red). - Color errorColor; - - /// Icon for message's `error` status. For the best look use size of 16. - Widget? errorIcon; - - /// Color of the bottom bar where text field is. - Color inputBackgroundColor; - - /// Surface Tint Color of the bottom bar where text field is. - Color inputSurfaceTintColor; - - double inputElevation; - - /// Top border radius of the bottom bar where text field is. - BorderRadius inputBorderRadius; - - /// Decoration of the container wrapping the text field. - Decoration? inputContainerDecoration; - - /// Outer insets of the bottom bar where text field is. - EdgeInsets inputMargin; - - /// Inner insets of the bottom bar where text field is. - EdgeInsets inputPadding; - - /// Color of the text field's text and attachment/send buttons. - Color inputTextColor; - - /// Color of the text field's cursor. - Color? inputTextCursorColor; - - /// Decoration of the input text field. - InputDecoration inputTextDecoration; - - /// Text style of the message input. To change the color use [inputTextColor]. - TextStyle inputTextStyle; - - /// Border radius of message container. - double messageBorderRadius; - - /// Horizontal message bubble insets. - double messageInsetsHorizontal; - - /// Vertical message bubble insets. - double messageInsetsVertical; - - /// Message bubble max width. set to [double.infinity] adaptive screen. - double messageMaxWidth; - - /// Primary color of the chat used as a background of sent messages - /// and statuses. - Color primaryColor; - - /// Text style used for displaying emojis on text messages. - TextStyle receivedEmojiMessageTextStyle; - - /// Body text style used for displaying bold text on received text messages. - /// Default to a bold version of [receivedMessageBodyTextStyle]. - TextStyle? receivedMessageBodyBoldTextStyle; - - /// Body text style used for displaying code text on received text messages. - /// Defaults to a mono version of [receivedMessageBodyTextStyle]. - TextStyle? receivedMessageBodyCodeTextStyle; - - /// Text style used for displaying link text on received text messages. - /// Defaults to [receivedMessageBodyTextStyle]. - TextStyle? receivedMessageBodyLinkTextStyle; - - /// Body text style used for displaying text on different types - /// of received messages. - TextStyle receivedMessageBodyTextStyle; - - /// Caption text style used for displaying secondary info (e.g. file size) on - /// different types of received messages. - TextStyle receivedMessageCaptionTextStyle; - - /// Color of the document icon on received messages. Has no effect when - /// [documentIcon] is used. - Color receivedMessageDocumentIconColor; - - /// Text style used for displaying link description on received messages. - TextStyle receivedMessageLinkDescriptionTextStyle; - - /// Text style used for displaying link title on received messages. - TextStyle receivedMessageLinkTitleTextStyle; - - /// Secondary color, used as a background of received messages. - Color secondaryColor; - - /// Icon for message's `seen` status. For the best look use size of 16. - Widget? seenIcon; - - /// Icon for send button. - Widget? sendButtonIcon; - - /// Margin of send button. - EdgeInsets? sendButtonMargin; - - /// Icon for message's `sending` status. For the best look use size of 10. - Widget? sendingIcon; - - /// Text style used for displaying emojis on text messages. - TextStyle sentEmojiMessageTextStyle; - - /// Body text style used for displaying bold text on sent text messages. - /// Defaults to a bold version of [sentMessageBodyTextStyle]. - TextStyle? sentMessageBodyBoldTextStyle; - - /// Body text style used for displaying code text on sent text messages. - /// Defaults to a mono version of [sentMessageBodyTextStyle]. - TextStyle? sentMessageBodyCodeTextStyle; - - /// Text style used for displaying link text on sent text messages. - /// Defaults to [sentMessageBodyTextStyle]. - TextStyle? sentMessageBodyLinkTextStyle; - - /// Body text style used for displaying text on different types - /// of sent messages. - TextStyle sentMessageBodyTextStyle; - - /// Caption text style used for displaying secondary info (e.g. file size) on - /// different types of sent messages. - TextStyle sentMessageCaptionTextStyle; - - /// Color of the document icon on sent messages. Has no effect when - /// [documentIcon] is used. - Color sentMessageDocumentIconColor; - - /// Text style used for displaying link description on sent messages. - TextStyle sentMessageLinkDescriptionTextStyle; - - /// Text style used for displaying link title on sent messages. - TextStyle sentMessageLinkTitleTextStyle; - - /// Padding around status icons. - EdgeInsets statusIconPadding; - - /// Theme for the system message. Will not have an effect if a custom builder - /// is provided. - SystemMessageTheme systemMessageTheme; - - /// Theme for typing indicator. See [TypingIndicator]. - TypingIndicatorTheme typingIndicatorTheme; - - /// Theme for the unread header. - UnreadHeaderTheme unreadHeaderTheme; - - /// Color used as a background for user avatar if an image is provided. - /// Visible if the image has some transparent parts. - Color userAvatarImageBackgroundColor; - - /// Colors used as backgrounds for user avatars with no image and so, - /// corresponding user names. - /// Calculated based on a user ID, so unique across the whole app. - List userAvatarNameColors; - - /// Text style used for displaying initials on user avatar if no - /// image is provided. - TextStyle userAvatarTextStyle; - - /// User names text style. Color will be overwritten - /// with [userAvatarNameColors]. - TextStyle userNameTextStyle; - - /// Color used as background of message row on highligth. - Color? highlightMessageColor; -} diff --git a/lib/theme/models/models.dart b/lib/theme/models/models.dart index be80542..7806ede 100644 --- a/lib/theme/models/models.dart +++ b/lib/theme/models/models.dart @@ -1,4 +1,3 @@ -export 'chat_theme.dart'; export 'radix_generator.dart'; export 'scale_theme/scale_theme.dart'; export 'theme_preference.dart'; diff --git a/lib/theme/models/scale_theme/scale_chat_theme.dart b/lib/theme/models/scale_theme/scale_chat_theme.dart new file mode 100644 index 0000000..5da0fe2 --- /dev/null +++ b/lib/theme/models/scale_theme/scale_chat_theme.dart @@ -0,0 +1,369 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; + +import 'scale_theme.dart'; + +class ScaleChatTheme { + ScaleChatTheme({ + // Default chat theme + required this.chatTheme, + + // Customization fields (from v1 of flutter chat ui) + required this.attachmentButtonIcon, + // required this.attachmentButtonMargin, + required this.backgroundColor, + required this.bubbleBorderSide, + // required this.dateDividerMargin, + // required this.chatContentMargin, + required this.dateDividerTextStyle, + // required this.deliveredIcon, + // required this.documentIcon, + // required this.emptyChatPlaceholderTextStyle, + // required this.errorColor, + // required this.errorIcon, + required this.inputBackgroundColor, + // required this.inputSurfaceTintColor, + // required this.inputElevation, + required this.inputBorderRadius, + // required this.inputMargin, + required this.inputPadding, + required this.inputTextColor, + required this.inputTextStyle, + required this.messageBorderRadius, + required this.messageInsetsHorizontal, + required this.messageInsetsVertical, + // required this.messageMaxWidth, + required this.primaryColor, + required this.receivedEmojiMessageTextStyle, + required this.receivedMessageBodyTextStyle, + // required this.receivedMessageCaptionTextStyle, + // required this.receivedMessageDocumentIconColor, + // required this.receivedMessageLinkDescriptionTextStyle, + // required this.receivedMessageLinkTitleTextStyle, + required this.secondaryColor, + // required this.seenIcon, + required this.sendButtonIcon, + // required this.sendButtonMargin, + // required this.sendingIcon, + required this.onlyEmojiFontSize, + required this.timeStyle, + required this.sentMessageBodyTextStyle, + // required this.sentMessageCaptionTextStyle, + // required this.sentMessageDocumentIconColor, + // required this.sentMessageLinkDescriptionTextStyle, + // required this.sentMessageLinkTitleTextStyle, + // required this.statusIconPadding, + // required this.userAvatarImageBackgroundColor, + // required this.userAvatarNameColors, + // required this.userAvatarTextStyle, + // required this.userNameTextStyle, + // required this.bubbleMargin, + required this.inputContainerDecoration, + // required this.inputTextCursorColor, + // required this.receivedMessageBodyBoldTextStyle, + // required this.receivedMessageBodyCodeTextStyle, + // required this.receivedMessageBodyLinkTextStyle, + // required this.sentMessageBodyBoldTextStyle, + // required this.sentMessageBodyCodeTextStyle, + // required this.sentMessageBodyLinkTextStyle, + // required this.highlightMessageColor, + }); + + final core.ChatTheme chatTheme; + + /// Icon for select attachment button. + final Widget? attachmentButtonIcon; + + /// Margin of attachment button. + // final EdgeInsets? attachmentButtonMargin; + + /// Used as a background color of a chat widget. + final Color backgroundColor; + + // Margin around the message bubble. + // final EdgeInsetsGeometry? bubbleMargin; + + /// Border for chat bubbles + final BorderSide bubbleBorderSide; + + /// Margin around date dividers. + // final EdgeInsets dateDividerMargin; + + /// Margin inside chat area. + // final EdgeInsets chatContentMargin; + + /// Text style of the date dividers. + final TextStyle dateDividerTextStyle; + + /// Icon for message's `delivered` status. For the best look use size of 16. + // final Widget? deliveredIcon; + + /// Icon inside file message. + // final Widget? documentIcon; + + /// Text style of the empty chat placeholder. + // final TextStyle emptyChatPlaceholderTextStyle; + + /// Color to indicate something bad happened (usually - shades of red). + // final Color errorColor; + + /// Icon for message's `error` status. For the best look use size of 16. + // final Widget? errorIcon; + + /// Color of the bottom bar where text field is. + final Color inputBackgroundColor; + + /// Surface Tint Color of the bottom bar where text field is. + // final Color inputSurfaceTintColor; + + /// Elevation to use for input material + // final double inputElevation; + + /// Top border radius of the bottom bar where text field is. + final BorderRadius inputBorderRadius; + + /// Decoration of the container wrapping the text field. + final Decoration? inputContainerDecoration; + + /// Outer insets of the bottom bar where text field is. + // final EdgeInsets inputMargin; + + /// Inner insets of the bottom bar where text field is. + final EdgeInsets inputPadding; + + /// Color of the text field's text and attachment/send buttons. + final Color inputTextColor; + + /// Color of the text field's cursor. + // final Color? inputTextCursorColor; + + /// Text style of the message input. To change the color use [inputTextColor]. + final TextStyle inputTextStyle; + + /// Border radius of message container. + final double messageBorderRadius; + + /// Horizontal message bubble insets. + final double messageInsetsHorizontal; + + /// Vertical message bubble insets. + final double messageInsetsVertical; + + /// Message bubble max width. set to [double.infinity] adaptive screen. + // final double messageMaxWidth; + + /// Primary color of the chat used as a background of sent messages + /// and statuses. + final Color primaryColor; + + /// Text style used for displaying emojis on text messages. + final TextStyle receivedEmojiMessageTextStyle; + + /// Body text style used for displaying bold text on received text messages. + // Default to a bold version of [receivedMessageBodyTextStyle]. + // final TextStyle? receivedMessageBodyBoldTextStyle; + + /// Body text style used for displaying code text on received text messages. + // Defaults to a mono version of [receivedMessageBodyTextStyle]. + // final TextStyle? receivedMessageBodyCodeTextStyle; + + /// Text style used for displaying link text on received text messages. + // Defaults to [receivedMessageBodyTextStyle]. + // final TextStyle? receivedMessageBodyLinkTextStyle; + + /// Body text style used for displaying text on different types + /// of received messages. + final TextStyle receivedMessageBodyTextStyle; + + /// Caption text style used for displaying secondary info (e.g. file size) on + /// different types of received messages. + // final TextStyle receivedMessageCaptionTextStyle; + + /// Color of the document icon on received messages. Has no effect when + // [documentIcon] is used. + // final Color receivedMessageDocumentIconColor; + + /// Text style used for displaying link description on received messages. + // final TextStyle receivedMessageLinkDescriptionTextStyle; + + /// Text style used for displaying link title on received messages. + // final TextStyle receivedMessageLinkTitleTextStyle; + + /// Secondary color, used as a background of received messages. + final Color secondaryColor; + + /// Icon for message's `seen` status. For the best look use size of 16. + // final Widget? seenIcon; + + /// Icon for send button. + final Widget? sendButtonIcon; + + /// Margin of send button. + // final EdgeInsets? sendButtonMargin; + + /// Icon for message's `sending` status. For the best look use size of 10. + // final Widget? sendingIcon; + + /// Text size for displaying emojis on text messages. + final double onlyEmojiFontSize; + + /// Text style used for time and status + final TextStyle timeStyle; + + /// Body text style used for displaying bold text on sent text messages. + /// Defaults to a bold version of [sentMessageBodyTextStyle]. + // final TextStyle? sentMessageBodyBoldTextStyle; + + /// Body text style used for displaying code text on sent text messages. + /// Defaults to a mono version of [sentMessageBodyTextStyle]. + // final TextStyle? sentMessageBodyCodeTextStyle; + + /// Text style used for displaying link text on sent text messages. + /// Defaults to [sentMessageBodyTextStyle]. + // final TextStyle? sentMessageBodyLinkTextStyle; + + /// Body text style used for displaying text on different types + /// of sent messages. + final TextStyle sentMessageBodyTextStyle; + + /// Caption text style used for displaying secondary info (e.g. file size) on + /// different types of sent messages. + // final TextStyle sentMessageCaptionTextStyle; + + /// Color of the document icon on sent messages. Has no effect when + // [documentIcon] is used. + // final Color sentMessageDocumentIconColor; + + /// Text style used for displaying link description on sent messages. + // final TextStyle sentMessageLinkDescriptionTextStyle; + + /// Text style used for displaying link title on sent messages. + // final TextStyle sentMessageLinkTitleTextStyle; + + /// Padding around status icons. + // final EdgeInsets statusIconPadding; + + /// Color used as a background for user avatar if an image is provided. + /// Visible if the image has some transparent parts. + // final Color userAvatarImageBackgroundColor; + + /// Colors used as backgrounds for user avatars with no image and so, + /// corresponding user names. + /// Calculated based on a user ID, so unique across the whole app. + // final List userAvatarNameColors; + + /// Text style used for displaying initials on user avatar if no + /// image is provided. + // final TextStyle userAvatarTextStyle; + + /// User names text style. Color will be overwritten with + // [userAvatarNameColors]. + // final TextStyle userNameTextStyle; + + /// Color used as background of message row on highlight. + // final Color? highlightMessageColor; +} + +extension ScaleChatThemeExt on ScaleTheme { + ScaleChatTheme chatTheme() { + // 'brightness' is not actually used by ChatColors.fromThemeData, + // or ChatTypography.fromThemeData so just say 'light' here + final themeData = toThemeData(Brightness.light); + final typography = core.ChatTypography.fromThemeData(themeData); + + final surfaceContainer = config.preferBorders + ? scheme.secondaryScale.calloutText + : scheme.secondaryScale.calloutBackground; + + final colors = core.ChatColors( + // Primary color, often used for sent messages and accents. + primary: config.preferBorders + ? scheme.primaryScale.calloutText + : scheme.primaryScale.calloutBackground, + // Color for text and icons displayed on top of [primary]. + onPrimary: scheme.primaryScale.primaryText, + // The main background color of the chat screen. + surface: + scheme.grayScale.appBackground.withAlpha(config.wallpaperAlpha), + + // Color for text and icons displayed on top of [surface]. + onSurface: scheme.primaryScale.appText, + + // Background color for elements like received messages. + surfaceContainer: surfaceContainer, + + // A slightly lighter/darker variant of [surfaceContainer]. + surfaceContainerLow: surfaceContainer.darken(25), + + // A slightly lighter/darker variant of [surfaceContainer]. + surfaceContainerHigh: surfaceContainer.lighten(25)); + + final chatTheme = core.ChatTheme( + colors: colors, + typography: typography, + shape: + BorderRadius.all(Radius.circular(config.borderRadiusScale * 12))); + + return ScaleChatTheme( + chatTheme: chatTheme, + primaryColor: config.preferBorders + ? scheme.primaryScale.calloutText + : scheme.primaryScale.calloutBackground, + secondaryColor: config.preferBorders + ? scheme.secondaryScale.calloutText + : scheme.secondaryScale.calloutBackground, + backgroundColor: + scheme.grayScale.appBackground.withAlpha(config.wallpaperAlpha), + messageBorderRadius: config.borderRadiusScale * 12, + bubbleBorderSide: config.preferBorders + ? BorderSide( + color: scheme.primaryScale.calloutBackground, + width: 2, + ) + : BorderSide(width: 2, color: Colors.black.withAlpha(96)), + sendButtonIcon: Image.asset( + 'assets/icon-send.png', + color: config.preferBorders + ? scheme.primaryScale.border + : scheme.primaryScale.borderText, + package: 'flutter_chat_ui', + ), + inputBackgroundColor: Colors.blue, + inputBorderRadius: BorderRadius.zero, + inputTextStyle: textTheme.bodyLarge!, + inputContainerDecoration: BoxDecoration( + border: config.preferBorders + ? Border( + top: + BorderSide(color: scheme.primaryScale.border, width: 2)) + : null, + color: config.preferBorders + ? scheme.primaryScale.elementBackground + : scheme.primaryScale.border), + inputPadding: const EdgeInsets.all(6), + inputTextColor: !config.preferBorders + ? scheme.primaryScale.appText + : scheme.primaryScale.border, + messageInsetsHorizontal: 12, + messageInsetsVertical: 8, + attachmentButtonIcon: const Icon(Icons.attach_file), + sentMessageBodyTextStyle: textTheme.bodyLarge!.copyWith( + color: config.preferBorders + ? scheme.primaryScale.calloutBackground + : scheme.primaryScale.calloutText, + ), + onlyEmojiFontSize: 64, + timeStyle: textTheme.bodySmall!.copyWith(fontSize: 9), + receivedMessageBodyTextStyle: textTheme.bodyLarge!.copyWith( + color: config.preferBorders + ? scheme.secondaryScale.calloutBackground + : scheme.secondaryScale.calloutText, + ), + receivedEmojiMessageTextStyle: const TextStyle( + color: Colors.white, + fontSize: 64, + ), + dateDividerTextStyle: textTheme.labelSmall!); + } +} diff --git a/lib/theme/models/scale_theme/scale_color.dart b/lib/theme/models/scale_theme/scale_color.dart index d79d878..f3c884f 100644 --- a/lib/theme/models/scale_theme/scale_color.dart +++ b/lib/theme/models/scale_theme/scale_color.dart @@ -83,6 +83,7 @@ class ScaleColor { calloutBackground: calloutBackground ?? this.calloutBackground, calloutText: calloutText ?? this.calloutText); + // Use static method // ignore: prefer_constructors_over_static_methods static ScaleColor lerp(ScaleColor a, ScaleColor b, double t) => ScaleColor( appBackground: Color.lerp(a.appBackground, b.appBackground, t) ?? diff --git a/lib/theme/models/scale_theme/scale_theme.dart b/lib/theme/models/scale_theme/scale_theme.dart index ef430d2..c3217ea 100644 --- a/lib/theme/models/scale_theme/scale_theme.dart +++ b/lib/theme/models/scale_theme/scale_theme.dart @@ -4,6 +4,7 @@ import 'scale_input_decorator_theme.dart'; import 'scale_scheme.dart'; export 'scale_app_bar_theme.dart'; +export 'scale_chat_theme.dart'; export 'scale_color.dart'; export 'scale_input_decorator_theme.dart'; export 'scale_scheme.dart'; diff --git a/lib/theme/views/responsive.dart b/lib/theme/views/responsive.dart index 91af81d..0182c9f 100644 --- a/lib/theme/views/responsive.dart +++ b/lib/theme/views/responsive.dart @@ -1,13 +1,24 @@ -import 'dart:io'; - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -bool get isAndroid => !kIsWeb && Platform.isAndroid; -bool get isiOS => !kIsWeb && Platform.isIOS; -bool get isWeb => kIsWeb; -bool get isDesktop => - !isWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS); +final isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android; +final isiOS = !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; +final isMobile = !kIsWeb && + (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android); +final isDesktop = !kIsWeb && + !(defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android); + +const isWeb = kIsWeb; +final isWebMobile = kIsWeb && + (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android); +final isWebDesktop = kIsWeb && + !(defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android); + +final isAnyMobile = isMobile || isWebMobile; const kMobileWidthCutoff = 500.0; diff --git a/lib/theme/views/styled_alert.dart b/lib/theme/views/styled_alert.dart index 8602c22..1215c84 100644 --- a/lib/theme/views/styled_alert.dart +++ b/lib/theme/views/styled_alert.dart @@ -128,7 +128,7 @@ Future showErrorStacktraceModal( await showErrorModal( context: context, title: translate('toast.error'), - text: 'Error: {e}\n StackTrace: {st}', + text: 'Error: $error\n StackTrace: $stackTrace', ); } diff --git a/lib/tools/loggy.dart b/lib/tools/loggy.dart index 47a1ffd..adc62d1 100644 --- a/lib/tools/loggy.dart +++ b/lib/tools/loggy.dart @@ -165,5 +165,8 @@ void initLoggy() { registerVeilidProtoToDebug(); registerVeilidDHTProtoToDebug(); registerVeilidchatProtoToDebug(); - Bloc.observer = const StateLogger(); + + if (kIsDebugMode) { + Bloc.observer = const StateLogger(); + } } diff --git a/lib/tools/state_logger.dart b/lib/tools/state_logger.dart index 50dec46..a133346 100644 --- a/lib/tools/state_logger.dart +++ b/lib/tools/state_logger.dart @@ -6,14 +6,17 @@ import 'package:veilid_support/veilid_support.dart'; import 'loggy.dart'; const Map _blocChangeLogLevels = { - 'ConnectionStateCubit': LogLevel.off, - 'ActiveSingleContactChatBlocMapCubit': LogLevel.off, - 'ActiveConversationsBlocMapCubit': LogLevel.off, - 'PersistentQueueCubit': LogLevel.off, - 'TableDBArrayProtobufCubit': LogLevel.off, - 'DHTLogCubit': LogLevel.off, - 'SingleContactMessagesCubit': LogLevel.off, - 'ChatComponentCubit': LogLevel.off, + 'RouterCubit': LogLevel.debug, + 'PerAccountCollectionBlocMapCubit': LogLevel.debug, + 'PerAccountCollectionCubit': LogLevel.debug, + 'ActiveChatCubit': LogLevel.debug, + 'AccountRecordCubit': LogLevel.debug, + 'ContactListCubit': LogLevel.debug, + 'ContactInvitationListCubit': LogLevel.debug, + 'ChatListCubit': LogLevel.debug, + 'PreferencesCubit': LogLevel.debug, + 'ConversationCubit': LogLevel.debug, + 'DefaultDHTRecordCubit': LogLevel.debug, }; const Map _blocCreateCloseLogLevels = {}; @@ -40,7 +43,7 @@ class StateLogger extends BlocObserver { @override void onChange(BlocBase bloc, Change change) { super.onChange(bloc, change); - _checkLogLevel(_blocChangeLogLevels, LogLevel.debug, bloc, (logLevel) { + _checkLogLevel(_blocChangeLogLevels, LogLevel.off, bloc, (logLevel) { const encoder = JsonEncoder.withIndent(' ', DynamicDebug.toDebug); log.log( logLevel, diff --git a/packages/veilid_support/lib/identity_support/super_identity.dart b/packages/veilid_support/lib/identity_support/super_identity.dart index 5ee8c43..5dc0e90 100644 --- a/packages/veilid_support/lib/identity_support/super_identity.dart +++ b/packages/veilid_support/lib/identity_support/super_identity.dart @@ -23,6 +23,7 @@ part 'super_identity.g.dart'; /// Encryption: None @freezed sealed class SuperIdentity with _$SuperIdentity { + @JsonSerializable() const factory SuperIdentity({ /// Public DHT record storing this structure for account recovery /// changing this can migrate/forward the SuperIdentity to a new DHT record diff --git a/packages/veilid_support/lib/identity_support/super_identity.freezed.dart b/packages/veilid_support/lib/identity_support/super_identity.freezed.dart index b142373..3144205 100644 --- a/packages/veilid_support/lib/identity_support/super_identity.freezed.dart +++ b/packages/veilid_support/lib/identity_support/super_identity.freezed.dart @@ -169,6 +169,7 @@ class _$SuperIdentityCopyWithImpl<$Res> } /// @nodoc + @JsonSerializable() class _SuperIdentity extends SuperIdentity { const _SuperIdentity( diff --git a/packages/veilid_support/lib/src/config.dart b/packages/veilid_support/lib/src/config.dart index 47bd84e..6902479 100644 --- a/packages/veilid_support/lib/src/config.dart +++ b/packages/veilid_support/lib/src/config.dart @@ -4,8 +4,10 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:veilid/veilid.dart'; +// Allowed to pull sentinel value // ignore: do_not_use_environment const bool kIsReleaseMode = bool.fromEnvironment('dart.vm.product'); +// Allowed to pull sentinel value // ignore: do_not_use_environment const bool kIsProfileMode = bool.fromEnvironment('dart.vm.profile'); const bool kIsDebugMode = !kIsReleaseMode && !kIsProfileMode; @@ -13,18 +15,21 @@ const bool kIsDebugMode = !kIsReleaseMode && !kIsProfileMode; Future> getDefaultVeilidPlatformConfig( bool isWeb, String appName) async { final ignoreLogTargetsStr = + // Allowed to change settings // ignore: do_not_use_environment const String.fromEnvironment('IGNORE_LOG_TARGETS').trim(); final ignoreLogTargets = ignoreLogTargetsStr.isEmpty ? [] : ignoreLogTargetsStr.split(',').map((e) => e.trim()).toList(); + // Allowed to change settings // ignore: do_not_use_environment var flamePathStr = const String.fromEnvironment('FLAME').trim(); if (flamePathStr == '1') { flamePathStr = p.join( (await getApplicationSupportDirectory()).absolute.path, '$appName.folded'); + // Allowed for debugging // ignore: avoid_print print('Flame data logged to $flamePathStr'); } @@ -73,30 +78,37 @@ Future getVeilidConfig(bool isWeb, String programName) async { var config = await getDefaultVeilidConfig( isWeb: isWeb, programName: programName, + // Allowed to change settings // ignore: avoid_redundant_argument_values, do_not_use_environment namespace: const String.fromEnvironment('NAMESPACE'), + // Allowed to change settings // ignore: avoid_redundant_argument_values, do_not_use_environment bootstrap: const String.fromEnvironment('BOOTSTRAP'), + // Allowed to change settings // ignore: avoid_redundant_argument_values, do_not_use_environment networkKeyPassword: const String.fromEnvironment('NETWORK_KEY'), ); + // Allowed to change settings // ignore: do_not_use_environment if (const String.fromEnvironment('DELETE_TABLE_STORE') == '1') { config = config.copyWith(tableStore: config.tableStore.copyWith(delete: true)); } + // Allowed to change settings // ignore: do_not_use_environment if (const String.fromEnvironment('DELETE_PROTECTED_STORE') == '1') { config = config.copyWith( protectedStore: config.protectedStore.copyWith(delete: true)); } + // Allowed to change settings // ignore: do_not_use_environment if (const String.fromEnvironment('DELETE_BLOCK_STORE') == '1') { config = config.copyWith(blockStore: config.blockStore.copyWith(delete: true)); } + // Allowed to change settings // ignore: do_not_use_environment const envNetwork = String.fromEnvironment('NETWORK'); if (envNetwork.isNotEmpty) { @@ -111,7 +123,8 @@ Future getVeilidConfig(bool isWeb, String programName) async { return config.copyWith( capabilities: - // XXX: Remove DHTV and DHTW when we get background sync implemented + // XXX: Remove DHTV and DHTW after DHT widening (and maybe remote + // rehydration?) const VeilidConfigCapabilities(disable: ['DHTV', 'DHTW', 'TUNL']), protectedStore: // XXX: Linux often does not have a secret storage mechanism installed diff --git a/packages/veilid_support/lib/src/table_db_array_protobuf_cubit.dart b/packages/veilid_support/lib/src/table_db_array_protobuf_cubit.dart index 81d7f57..92b920f 100644 --- a/packages/veilid_support/lib/src/table_db_array_protobuf_cubit.dart +++ b/packages/veilid_support/lib/src/table_db_array_protobuf_cubit.dart @@ -24,7 +24,7 @@ class TableDBArrayProtobufStateData final IList windowElements; // The length of the entire array final int length; - // One past the end of the last element + // One past the end of the last element (modulo length, can be zero) final int windowTail; // The total number of elements to try to keep in 'elements' final int windowCount; diff --git a/pubspec.lock b/pubspec.lock index e7252a3..947b17e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -393,6 +393,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_cache: + dependency: transitive + description: + name: cross_cache + sha256: "007d0340c19d4d201192a3335c4034f4b79eae5ea53f90b69eeb5d239d9fbd1d" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cross_file: dependency: transitive description: @@ -542,23 +550,20 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" - flutter_chat_types: + flutter_chat_core: dependency: "direct main" description: - name: flutter_chat_types - sha256: e285b588f6d19d907feb1f6d912deaf22e223656769c34093b64e1c59b094fb9 - url: "https://pub.dev" - source: hosted - version: "3.6.2" + path: "../flutter_chat_ui/packages/flutter_chat_core" + relative: true + source: path + version: "2.1.2" flutter_chat_ui: dependency: "direct main" description: - path: "." - ref: main - resolved-ref: d4b9d507d10f5d640156cacfd754f661f8c0f4c1 - url: "https://gitlab.com/veilid/flutter-chat-ui.git" - source: git - version: "1.6.14" + path: "../flutter_chat_ui/packages/flutter_chat_ui" + relative: true + source: path + version: "2.1.3" flutter_form_builder: dependency: "direct main" description: @@ -575,22 +580,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.21.2" - flutter_link_previewer: - dependency: transitive - description: - name: flutter_link_previewer - sha256: "007069e60f42419fb59872beb7a3cc3ea21e9f1bdff5d40239f376fa62ca9f20" - url: "https://pub.dev" - source: hosted - version: "3.2.2" - flutter_linkify: - dependency: transitive - description: - name: flutter_linkify - sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073" - url: "https://pub.dev" - source: hosted - version: "6.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -604,14 +593,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.5" - flutter_parsed_text: - dependency: transitive - description: - name: flutter_parsed_text - sha256: "529cf5793b7acdf16ee0f97b158d0d4ba0bf06e7121ef180abe1a5b59e32c1e2" - url: "https://pub.dev" - source: hosted - version: "2.2.1" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -801,6 +782,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + idb_shim: + dependency: transitive + description: + name: idb_shim + sha256: d3dae2085f2dcc9d05b851331fddb66d57d3447ff800de9676b396795436e135 + url: "https://pub.dev" + source: hosted + version: "2.6.5+1" image: dependency: "direct main" description: @@ -857,14 +846,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.9.4" - linkify: - dependency: transitive + keyboard_avoider: + dependency: "direct main" description: - name: linkify - sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" + name: keyboard_avoider + sha256: d2917bd52c6612bf8d1ff97f74049ddf3592a81d44e814f0e7b07dcfd245b75c url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "0.2.0" lint_hard: dependency: "direct dev" description: @@ -1073,14 +1062,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" - photo_view: - dependency: transitive - description: - name: photo_view - sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e" - url: "https://pub.dev" - source: hosted - version: "0.15.0" pinput: dependency: "direct main" description: @@ -1157,10 +1138,10 @@ packages: dependency: "direct main" description: name: provider - sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "6.1.5" pub_semver: dependency: transitive description: @@ -1305,6 +1286,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + scrollview_observer: + dependency: transitive + description: + name: scrollview_observer + sha256: "437c930927c5a3240ed2d40398f99d96eaca58f861817ff44f6d0c60113bcf9d" + url: "https://pub.dev" + source: hosted + version: "1.26.0" searchable_listview: dependency: "direct main" description: @@ -1314,6 +1303,14 @@ packages: url: "https://gitlab.com/veilid/Searchable-Listview.git" source: git version: "2.16.0" + sembast: + dependency: transitive + description: + name: sembast + sha256: d3f0d0ba501a5f1fd7d6c8532ee01385977c8a069c334635dae390d059ae3d6d + url: "https://pub.dev" + source: hosted + version: "3.8.5" share_plus: dependency: "direct main" description: @@ -1774,7 +1771,7 @@ packages: path: "../veilid/veilid-flutter" relative: true source: path - version: "0.4.4" + version: "0.4.6" veilid_support: dependency: "direct main" description: @@ -1782,14 +1779,6 @@ packages: relative: true source: path version: "1.0.2+0" - visibility_detector: - dependency: transitive - description: - name: visibility_detector - sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 - url: "https://pub.dev" - source: hosted - version: "0.4.0+2" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ac985bf..9cf61e8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,11 +1,11 @@ name: veilidchat description: VeilidChat -publish_to: 'none' +publish_to: "none" version: 0.4.7+20 environment: - sdk: '>=3.2.0 <4.0.0' - flutter: '>=3.22.1' + sdk: ">=3.2.0 <4.0.0" + flutter: ">=3.22.1" dependencies: accordion: ^2.6.0 @@ -37,11 +37,16 @@ dependencies: sdk: flutter flutter_animate: ^4.5.2 flutter_bloc: ^9.1.0 - flutter_chat_types: ^3.6.2 + flutter_chat_core: + git: + url: https://gitlab.com/veilid/flutter-chat-ui.git + path: packages/flutter_chat_core + ref: veilidchat flutter_chat_ui: git: url: https://gitlab.com/veilid/flutter-chat-ui.git - ref: main + path: packages/flutter_chat_ui + ref: veilidchat flutter_form_builder: ^10.0.1 flutter_hooks: ^0.21.2 flutter_localizations: @@ -59,6 +64,7 @@ dependencies: image: ^4.5.3 intl: ^0.19.0 json_annotation: ^4.9.0 + keyboard_avoider: ^0.2.0 loggy: ^2.0.3 meta: ^1.16.0 mobile_scanner: ^6.0.7 @@ -110,15 +116,17 @@ dependencies: xterm: ^4.0.0 zxing2: ^0.2.3 -# dependency_overrides: -# async_tools: -# path: ../dart_async_tools -# bloc_advanced_tools: -# path: ../bloc_advanced_tools -# searchable_listview: -# path: ../Searchable-Listview -# flutter_chat_ui: -# path: ../flutter_chat_ui +dependency_overrides: + # async_tools: + # path: ../dart_async_tools + # bloc_advanced_tools: + # path: ../bloc_advanced_tools + # searchable_listview: + # path: ../Searchable-Listview + flutter_chat_core: + path: ../flutter_chat_ui/packages/flutter_chat_core + flutter_chat_ui: + path: ../flutter_chat_ui/packages/flutter_chat_ui dev_dependencies: build_runner: ^2.4.15 @@ -129,22 +137,22 @@ dev_dependencies: flutter_native_splash: color: "#8588D0" - + icons_launcher: - image_path: 'assets/launcher/icon.png' + image_path: "assets/launcher/icon.png" platforms: android: enable: true - adaptive_background_color: '#ffffff' - adaptive_foreground_image: 'assets/launcher/icon.png' - adaptive_round_image: 'assets/launcher/icon.png' + adaptive_background_color: "#ffffff" + adaptive_foreground_image: "assets/launcher/icon.png" + adaptive_round_image: "assets/launcher/icon.png" ios: enable: true web: enable: true macos: enable: true - image_path: 'assets/launcher/macos_icon.png' + image_path: "assets/launcher/macos_icon.png" windows: enable: true linux: @@ -192,7 +200,7 @@ flutter: - asset: assets/fonts/SourceCodePro-Regular.ttf - asset: assets/fonts/SourceCodePro-Bold.ttf weight: 700 - + # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware