new chat widget

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

View file

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

View file

@ -313,5 +313,9 @@
"info": "Info",
"debug": "Debug",
"trace": "Trace"
},
"date_formatter": {
"just_now": "Just now",
"yesterday": "Yesterday"
}
}

0
flutter_01.png Normal file
View file

0
flutter_02.png Normal file
View file

0
flutter_03.png Normal file
View file

View file

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

View file

@ -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<String, dynamic> json) =>
_$LocalAccountFromJson(json);
// The super identity key record for the account,
// containing the publicKey in the currentIdentity

View file

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

View file

@ -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<String, dynamic> json) =>
_$UserLoginFromJson(json);
// SuperIdentity record key for the user
// used to index the local accounts table

View file

@ -249,7 +249,6 @@ class _EditAccountPageState extends WindowSetupState<EditAccountPage> {
final displayModalHUD = _isInAsyncCall;
return StyledScaffold(
// resizeToAvoidBottomInset: false,
appBar: DefaultAppBar(
title: Text(translate('edit_account_page.titlebar')),
leading: Navigator.canPop(context)

View file

@ -163,7 +163,6 @@ class _ShowRecoveryKeyPageState extends WindowSetupState<ShowRecoveryKeyPage> {
final displayModalHUD = _isInAsyncCall;
return StyledScaffold(
// resizeToAvoidBottomInset: false,
appBar: DefaultAppBar(
title: Text(translate('show_recovery_key_page.titlebar')),
actions: [

View file

@ -4,11 +4,8 @@ import 'dart:typed_data';
import 'package:async_tools/async_tools.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart' as core;
import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
@ -19,6 +16,7 @@ import '../../tools/tools.dart';
import '../models/chat_component_state.dart';
import '../models/message_state.dart';
import '../models/window_state.dart';
import '../views/chat_component_widget.dart';
import 'cubits.dart';
const metadataKeyIdentityPublicKey = 'identityPublicKey';
@ -39,15 +37,12 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
_contactListCubit = contactListCubit,
_conversationCubits = conversationCubits,
_messagesCubit = messagesCubit,
super(ChatComponentState(
chatKey: GlobalKey<ChatState>(),
scrollController: AutoScrollController(),
textEditingController: InputTextFieldController(),
super(const ChatComponentState(
localUser: null,
remoteUsers: const IMap.empty(),
historicalRemoteUsers: const IMap.empty(),
unknownUsers: const IMap.empty(),
messageWindow: const AsyncLoading(),
remoteUsers: IMap.empty(),
historicalRemoteUsers: IMap.empty(),
unknownUsers: IMap.empty(),
messageWindow: AsyncLoading(),
title: '',
)) {
// Immediate Init
@ -102,6 +97,7 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
await _accountRecordSubscription.cancel();
await _messagesSubscription.cancel();
await _conversationSubscriptions.values.map((v) => v.cancel()).wait;
await super.close();
}
@ -122,32 +118,15 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
}
// Send a message
void sendMessage(types.PartialText message) {
final text = message.text;
final replyId = (message.repliedMessage != null)
? base64UrlNoPadDecode(message.repliedMessage!.id)
void sendMessage(
{required String text,
String? replyToMessageId,
Timestamp? expiration,
int? viewLimit,
List<proto.Attachment>? attachments}) {
final replyId = (replyToMessageId != null)
? base64UrlNoPadDecode(replyToMessageId)
: null;
Timestamp? expiration;
int? viewLimit;
List<proto.Attachment>? attachments;
final metadata = message.metadata;
if (metadata != null) {
final expirationValue =
metadata[metadataKeyExpirationDuration] as TimestampDuration?;
if (expirationValue != null) {
expiration = Veilid.instance.now().offset(expirationValue);
}
final viewLimitValue = metadata[metadataKeyViewLimit] as int?;
if (viewLimitValue != null) {
viewLimit = viewLimitValue;
}
final attachmentsValue =
metadata[metadataKeyAttachments] as List<proto.Attachment>?;
if (attachmentsValue != null) {
attachments = attachmentsValue;
}
}
_addTextMessage(
text: text,
@ -172,9 +151,9 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
emit(state.copyWith(localUser: null));
return;
}
final localUser = types.User(
final localUser = core.User(
id: _localUserIdentityKey.toString(),
firstName: account.profile.name,
name: account.profile.name,
metadata: {metadataKeyIdentityPublicKey: _localUserIdentityKey});
emit(state.copyWith(localUser: localUser));
}
@ -199,11 +178,12 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
// Don't change user information on loading state
return;
}
final remoteUser =
_convertRemoteUser(remoteIdentityPublicKey, activeConversationState);
emit(_updateTitle(state.copyWith(
remoteUsers: state.remoteUsers.add(
remoteIdentityPublicKey,
_convertRemoteUser(
remoteIdentityPublicKey, activeConversationState)))));
remoteUsers: state.remoteUsers.add(remoteUser.id, remoteUser))));
}
static ChatComponentState _updateTitle(ChatComponentState currentState) {
@ -212,13 +192,13 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
}
if (currentState.remoteUsers.length == 1) {
final remoteUser = currentState.remoteUsers.values.first;
return currentState.copyWith(title: remoteUser.firstName ?? '<unnamed>');
return currentState.copyWith(title: remoteUser.name ?? '<unnamed>');
}
return currentState.copyWith(
title: '<group chat with ${currentState.remoteUsers.length} users>');
}
types.User _convertRemoteUser(TypedKey remoteIdentityPublicKey,
core.User _convertRemoteUser(TypedKey remoteIdentityPublicKey,
ActiveConversationState activeConversationState) {
// See if we have a contact for this remote user
final contacts = _contactListCubit.state.state.asData?.value;
@ -227,24 +207,23 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
x.value.identityPublicKey.toVeilid() == remoteIdentityPublicKey);
if (contactIdx != -1) {
final contact = contacts[contactIdx].value;
return types.User(
return core.User(
id: remoteIdentityPublicKey.toString(),
firstName: contact.displayName,
name: contact.displayName,
metadata: {metadataKeyIdentityPublicKey: remoteIdentityPublicKey});
}
}
return types.User(
return core.User(
id: remoteIdentityPublicKey.toString(),
firstName: activeConversationState.remoteConversation?.profile.name ??
name: activeConversationState.remoteConversation?.profile.name ??
'<unnamed>',
metadata: {metadataKeyIdentityPublicKey: remoteIdentityPublicKey});
}
types.User _convertUnknownUser(TypedKey remoteIdentityPublicKey) =>
types.User(
core.User _convertUnknownUser(TypedKey remoteIdentityPublicKey) => core.User(
id: remoteIdentityPublicKey.toString(),
firstName: '<$remoteIdentityPublicKey>',
name: '<$remoteIdentityPublicKey>',
metadata: {metadataKeyIdentityPublicKey: remoteIdentityPublicKey});
Future<void> _updateConversationSubscriptions() async {
@ -267,16 +246,17 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
final activeConversationState = cc.state.asData?.value;
if (activeConversationState != null) {
currentRemoteUsersState = currentRemoteUsersState.add(
remoteIdentityPublicKey,
_convertRemoteUser(
remoteIdentityPublicKey, activeConversationState));
final remoteUser = _convertRemoteUser(
remoteIdentityPublicKey, activeConversationState);
currentRemoteUsersState =
currentRemoteUsersState.add(remoteUser.id, remoteUser);
}
}
// Purge remote users we didn't see in the cubit list any more
final cancels = <Future<void>>[];
for (final deadUser in existing) {
currentRemoteUsersState = currentRemoteUsersState.remove(deadUser);
currentRemoteUsersState =
currentRemoteUsersState.remove(deadUser.toString());
cancels.add(_conversationSubscriptions.remove(deadUser)!.cancel());
}
await cancels.wait;
@ -285,63 +265,76 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
emit(_updateTitle(state.copyWith(remoteUsers: currentRemoteUsersState)));
}
(ChatComponentState, types.Message?) _messageStateToChatMessage(
(ChatComponentState, core.Message?) _messageStateToChatMessage(
ChatComponentState currentState, MessageState message) {
final authorIdentityPublicKey = message.content.author.toVeilid();
late final types.User author;
final authorUserId = authorIdentityPublicKey.toString();
late final core.User author;
if (authorIdentityPublicKey == _localUserIdentityKey &&
currentState.localUser != null) {
author = currentState.localUser!;
} else {
final remoteUser = currentState.remoteUsers[authorIdentityPublicKey];
final remoteUser = currentState.remoteUsers[authorUserId];
if (remoteUser != null) {
author = remoteUser;
} else {
final historicalRemoteUser =
currentState.historicalRemoteUsers[authorIdentityPublicKey];
currentState.historicalRemoteUsers[authorUserId];
if (historicalRemoteUser != null) {
author = historicalRemoteUser;
} else {
final unknownRemoteUser =
currentState.unknownUsers[authorIdentityPublicKey];
final unknownRemoteUser = currentState.unknownUsers[authorUserId];
if (unknownRemoteUser != null) {
author = unknownRemoteUser;
} else {
final unknownUser = _convertUnknownUser(authorIdentityPublicKey);
currentState = currentState.copyWith(
unknownUsers: currentState.unknownUsers
.add(authorIdentityPublicKey, unknownUser));
unknownUsers:
currentState.unknownUsers.add(authorUserId, unknownUser));
author = unknownUser;
}
}
}
}
types.Status? status;
if (message.sendState != null) {
assert(author.id == _localUserIdentityKey.toString(),
'send state should only be on sent messages');
switch (message.sendState!) {
case MessageSendState.sending:
status = types.Status.sending;
case MessageSendState.sent:
status = types.Status.sent;
case MessageSendState.delivered:
status = types.Status.delivered;
}
}
// types.Status? status;
// if (message.sendState != null) {
// assert(author.id == _localUserIdentityKey.toString(),
// 'send state should only be on sent messages');
// switch (message.sendState!) {
// case MessageSendState.sending:
// status = types.Status.sending;
// case MessageSendState.sent:
// status = types.Status.sent;
// case MessageSendState.delivered:
// status = types.Status.delivered;
// }
// }
final reconciledAt = message.reconciledTimestamp == null
? null
: DateTime.fromMicrosecondsSinceEpoch(
message.reconciledTimestamp!.value.toInt());
// print('message seqid: ${message.seqId}');
switch (message.content.whichKind()) {
case proto.Message_Kind.text:
final contextText = message.content.text;
final textMessage = types.TextMessage(
author: author,
createdAt:
(message.sentTimestamp.value ~/ BigInt.from(1000)).toInt(),
id: message.content.authorUniqueIdString,
text: contextText.text,
showStatus: status != null,
status: status);
final reconciledId = message.content.authorUniqueIdString;
final contentText = message.content.text;
final textMessage = core.TextMessage(
authorId: author.id,
createdAt: DateTime.fromMicrosecondsSinceEpoch(
message.sentTimestamp.value.toInt()),
sentAt: reconciledAt,
id: reconciledId,
text: '${contentText.text} (${message.seqId})',
//text: contentText.text,
metadata: {
kSeqId: message.seqId,
if (core.isOnlyEmoji(contentText.text)) 'isOnlyEmoji': true,
});
return (currentState, textMessage);
case proto.Message_Kind.secret:
case proto.Message_Kind.delete:
@ -375,7 +368,7 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
final messagesState = avMessagesState.asData!.value;
// Convert protobuf messages to chat messages
final chatMessages = <types.Message>[];
final chatMessages = <core.Message>[];
final tsSet = <String>{};
for (final message in messagesState.window) {
final (newState, chatMessage) =
@ -390,11 +383,11 @@ class ChatComponentCubit extends Cubit<ChatComponentState> {
// '\nChatMessages:\n$chatMessages'
);
} else {
chatMessages.insert(0, chatMessage);
chatMessages.add(chatMessage);
}
}
return currentState.copyWith(
messageWindow: AsyncValue.data(WindowState<types.Message>(
messageWindow: AsyncValue.data(WindowState<core.Message>(
window: chatMessages.toIList(),
length: messagesState.length,
windowTail: messagesState.windowTail,

View file

@ -3,6 +3,8 @@ import 'dart:async';
import 'package:async_tools/async_tools.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:uuid/uuid.dart';
import 'package:uuid/v4.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
@ -11,9 +13,12 @@ import '../../tools/tools.dart';
import '../models/models.dart';
import 'reconciliation/reconciliation.dart';
const _sfSendMessageTag = 'sfSendMessageTag';
class RenderStateElement {
RenderStateElement(
{required this.message,
{required this.seqId,
required this.message,
required this.isLocal,
this.reconciledTimestamp,
this.sent = false,
@ -36,6 +41,7 @@ class RenderStateElement {
return null;
}
int seqId;
proto.Message message;
bool isLocal;
Timestamp? reconciledTimestamp;
@ -71,6 +77,8 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
Future<void> close() async {
await _initWait();
await serialFutureClose((this, _sfSendMessageTag));
await _commandController.close();
await _commandRunnerFut;
await _unsentMessagesQueue.close();
@ -309,9 +317,6 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
// Async process to send messages in the background
Future<void> _processUnsentMessages(IList<proto.Message> messages) async {
// _sendingMessages = messages;
// _renderState();
try {
await _sentMessagesDHTLog!.operateAppendEventual((writer) async {
// Get the previous message if we have one
@ -337,8 +342,6 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
} on Exception catch (e, st) {
log.error('Exception appending unsent messages: $e:\n$st\n');
}
// _sendingMessages = const IList.empty();
}
// Produce a state for this cubit from the input cubits and queues
@ -349,8 +352,9 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
// Get all sent messages that are still offline
//final sentMessages = _sentMessagesDHTLog.
// Get all items in the unsent queue
//final unsentMessages = _unsentMessagesQueue.queue;
final unsentMessages = _unsentMessagesQueue.queue;
// If we aren't ready to render a state, say we're loading
if (reconciledMessages == null) {
@ -374,8 +378,19 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
// values: unsentMessages,
// );
// List of all rendered state elements that we will turn into
// message states
final renderedElements = <RenderStateElement>[];
// Keep track of the ids we have rendered
// because there can be an overlap between the 'unsent messages'
// and the reconciled messages as the async state catches up
final renderedIds = <String>{};
var seqId = (reconciledMessages.windowTail == 0
? reconciledMessages.length
: reconciledMessages.windowTail) -
reconciledMessages.windowElements.length;
for (final m in reconciledMessages.windowElements) {
final isLocal =
m.content.author.toVeilid() == _accountInfo.identityTypedPublicKey;
@ -387,33 +402,44 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
final sent = isLocal;
final sentOffline = false; //
if (renderedIds.contains(m.content.authorUniqueIdString)) {
seqId++;
continue;
}
renderedElements.add(RenderStateElement(
seqId: seqId,
message: m.content,
isLocal: isLocal,
reconciledTimestamp: reconciledTimestamp,
sent: sent,
sentOffline: sentOffline,
));
renderedIds.add(m.content.authorUniqueIdString);
seqId++;
}
// Render in-flight messages at the bottom
// for (final m in _sendingMessages) {
//
// for (final m in unsentMessages) {
// if (renderedIds.contains(m.authorUniqueIdString)) {
// seqId++;
// continue;
// }
// renderedElements.add(RenderStateElement(
// seqId: seqId,
// message: m,
// isLocal: true,
// sent: true,
// sentOffline: true,
// ));
// renderedIds.add(m.authorUniqueIdString);
// seqId++;
// }
// Render the state
final messages = renderedElements
.map((x) => MessageState(
seqId: x.seqId,
content: x.message,
sentTimestamp: Timestamp.fromInt64(x.message.timestamp),
reconciledTimestamp: x.reconciledTimestamp,
@ -431,20 +457,26 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
void _sendMessage({required proto.Message message}) {
// Add common fields
// id and signature will get set by _processMessageToSend
// real id and signature will get set by _processMessageToSend
// temporary id set here is random and not 'valid' in the eyes
// of reconcilation, noting that reconciled timestamp is not yet set.
message
..author = _accountInfo.identityTypedPublicKey.toProto()
..timestamp = Veilid.instance.now().toInt64();
..timestamp = Veilid.instance.now().toInt64()
..id = Uuid.parse(_uuidGen.generate());
if ((message.writeToBuffer().lengthInBytes + 256) > 4096) {
throw const FormatException('message is too long');
}
// Put in the queue
_unsentMessagesQueue.addSync(message);
serialFuture((this, _sfSendMessageTag), () async {
// Add the message to the persistent queue
await _unsentMessagesQueue.add(message);
// Update the view
_renderState();
});
}
Future<void> _commandRunner() async {
@ -487,7 +519,6 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
late final MessageReconciliation _reconciliation;
late final PersistentQueue<proto.Message> _unsentMessagesQueue;
// IList<proto.Message> _sendingMessages = const IList.empty();
StreamSubscription<void>? _sentSubscription;
StreamSubscription<void>? _rcvdSubscription;
StreamSubscription<TableDBArrayProtobufBusyState<proto.ReconciledMessage>>?
@ -496,4 +527,5 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
late final Future<void> _commandRunnerFut;
final _sspRemoteConversationRecordKey = SingleStateProcessor<TypedKey?>();
final _uuidGen = const UuidV4();
}

View file

@ -1,12 +1,7 @@
import 'package:async_tools/async_tools.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' show Message, User;
import 'package:flutter_chat_ui/flutter_chat_ui.dart'
show ChatState, InputTextFieldController;
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:veilid_support/veilid_support.dart';
import 'window_state.dart';
@ -16,26 +11,16 @@ part 'chat_component_state.freezed.dart';
sealed class ChatComponentState with _$ChatComponentState {
const factory ChatComponentState(
{
// GlobalKey for the chat
required GlobalKey<ChatState> chatKey,
// ScrollController for the chat
required AutoScrollController scrollController,
// TextEditingController for the chat
required InputTextFieldController textEditingController,
// Local user
required User? localUser,
// Active remote users
required IMap<TypedKey, User> remoteUsers,
required IMap<UserID, User> remoteUsers,
// Historical remote users
required IMap<TypedKey, User> historicalRemoteUsers,
required IMap<UserID, User> historicalRemoteUsers,
// Unknown users
required IMap<TypedKey, User> unknownUsers,
required IMap<UserID, User> unknownUsers,
// Messages state
required AsyncValue<WindowState<Message>> messageWindow,
// Title of the chat
required String title}) = _ChatComponentState;
}
extension ChatComponentStateExt on ChatComponentState {
//
}

View file

@ -15,15 +15,11 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ChatComponentState {
// GlobalKey for the chat
GlobalKey<ChatState> get chatKey; // ScrollController for the chat
AutoScrollController
get scrollController; // TextEditingController for the chat
InputTextFieldController get textEditingController; // Local user
// Local user
User? get localUser; // Active remote users
IMap<TypedKey, User> get remoteUsers; // Historical remote users
IMap<TypedKey, User> get historicalRemoteUsers; // Unknown users
IMap<TypedKey, User> get unknownUsers; // Messages state
IMap<UserID, User> get remoteUsers; // Historical remote users
IMap<UserID, User> get historicalRemoteUsers; // Unknown users
IMap<UserID, User> get unknownUsers; // Messages state
AsyncValue<WindowState<Message>> get messageWindow; // Title of the chat
String get title;
@ -40,11 +36,6 @@ mixin _$ChatComponentState {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is ChatComponentState &&
(identical(other.chatKey, chatKey) || other.chatKey == chatKey) &&
(identical(other.scrollController, scrollController) ||
other.scrollController == scrollController) &&
(identical(other.textEditingController, textEditingController) ||
other.textEditingController == textEditingController) &&
(identical(other.localUser, localUser) ||
other.localUser == localUser) &&
(identical(other.remoteUsers, remoteUsers) ||
@ -59,21 +50,12 @@ mixin _$ChatComponentState {
}
@override
int get hashCode => Object.hash(
runtimeType,
chatKey,
scrollController,
textEditingController,
localUser,
remoteUsers,
historicalRemoteUsers,
unknownUsers,
messageWindow,
title);
int get hashCode => Object.hash(runtimeType, localUser, remoteUsers,
historicalRemoteUsers, unknownUsers, messageWindow, title);
@override
String toString() {
return 'ChatComponentState(chatKey: $chatKey, scrollController: $scrollController, textEditingController: $textEditingController, localUser: $localUser, remoteUsers: $remoteUsers, historicalRemoteUsers: $historicalRemoteUsers, unknownUsers: $unknownUsers, messageWindow: $messageWindow, title: $title)';
return 'ChatComponentState(localUser: $localUser, remoteUsers: $remoteUsers, historicalRemoteUsers: $historicalRemoteUsers, unknownUsers: $unknownUsers, messageWindow: $messageWindow, title: $title)';
}
}
@ -84,16 +66,14 @@ abstract mixin class $ChatComponentStateCopyWith<$Res> {
_$ChatComponentStateCopyWithImpl;
@useResult
$Res call(
{GlobalKey<ChatState> chatKey,
AutoScrollController scrollController,
InputTextFieldController textEditingController,
User? localUser,
IMap<Typed<FixedEncodedString43>, User> remoteUsers,
IMap<Typed<FixedEncodedString43>, User> historicalRemoteUsers,
IMap<Typed<FixedEncodedString43>, User> unknownUsers,
{User? localUser,
IMap<String, User> remoteUsers,
IMap<String, User> historicalRemoteUsers,
IMap<String, User> unknownUsers,
AsyncValue<WindowState<Message>> messageWindow,
String title});
$UserCopyWith<$Res>? get localUser;
$AsyncValueCopyWith<WindowState<Message>, $Res> get messageWindow;
}
@ -110,9 +90,6 @@ class _$ChatComponentStateCopyWithImpl<$Res>
@pragma('vm:prefer-inline')
@override
$Res call({
Object? chatKey = null,
Object? scrollController = null,
Object? textEditingController = null,
Object? localUser = freezed,
Object? remoteUsers = null,
Object? historicalRemoteUsers = null,
@ -121,18 +98,6 @@ class _$ChatComponentStateCopyWithImpl<$Res>
Object? title = null,
}) {
return _then(_self.copyWith(
chatKey: null == chatKey
? _self.chatKey
: chatKey // ignore: cast_nullable_to_non_nullable
as GlobalKey<ChatState>,
scrollController: null == scrollController
? _self.scrollController
: scrollController // ignore: cast_nullable_to_non_nullable
as AutoScrollController,
textEditingController: null == textEditingController
? _self.textEditingController
: textEditingController // ignore: cast_nullable_to_non_nullable
as InputTextFieldController,
localUser: freezed == localUser
? _self.localUser
: localUser // ignore: cast_nullable_to_non_nullable
@ -140,15 +105,15 @@ class _$ChatComponentStateCopyWithImpl<$Res>
remoteUsers: null == remoteUsers
? _self.remoteUsers!
: remoteUsers // ignore: cast_nullable_to_non_nullable
as IMap<Typed<FixedEncodedString43>, User>,
as IMap<String, User>,
historicalRemoteUsers: null == historicalRemoteUsers
? _self.historicalRemoteUsers!
: historicalRemoteUsers // ignore: cast_nullable_to_non_nullable
as IMap<Typed<FixedEncodedString43>, User>,
as IMap<String, User>,
unknownUsers: null == unknownUsers
? _self.unknownUsers!
: unknownUsers // ignore: cast_nullable_to_non_nullable
as IMap<Typed<FixedEncodedString43>, User>,
as IMap<String, User>,
messageWindow: null == messageWindow
? _self.messageWindow
: messageWindow // ignore: cast_nullable_to_non_nullable
@ -160,6 +125,20 @@ class _$ChatComponentStateCopyWithImpl<$Res>
));
}
/// Create a copy of ChatComponentState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$UserCopyWith<$Res>? get localUser {
if (_self.localUser == null) {
return null;
}
return $UserCopyWith<$Res>(_self.localUser!, (value) {
return _then(_self.copyWith(localUser: value));
});
}
/// Create a copy of ChatComponentState
/// with the given fields replaced by the non-null parameter values.
@override
@ -176,37 +155,25 @@ class _$ChatComponentStateCopyWithImpl<$Res>
class _ChatComponentState implements ChatComponentState {
const _ChatComponentState(
{required this.chatKey,
required this.scrollController,
required this.textEditingController,
required this.localUser,
{required this.localUser,
required this.remoteUsers,
required this.historicalRemoteUsers,
required this.unknownUsers,
required this.messageWindow,
required this.title});
// GlobalKey for the chat
@override
final GlobalKey<ChatState> chatKey;
// ScrollController for the chat
@override
final AutoScrollController scrollController;
// TextEditingController for the chat
@override
final InputTextFieldController textEditingController;
// Local user
@override
final User? localUser;
// Active remote users
@override
final IMap<Typed<FixedEncodedString43>, User> remoteUsers;
final IMap<String, User> remoteUsers;
// Historical remote users
@override
final IMap<Typed<FixedEncodedString43>, User> historicalRemoteUsers;
final IMap<String, User> historicalRemoteUsers;
// Unknown users
@override
final IMap<Typed<FixedEncodedString43>, User> unknownUsers;
final IMap<String, User> unknownUsers;
// Messages state
@override
final AsyncValue<WindowState<Message>> messageWindow;
@ -227,11 +194,6 @@ class _ChatComponentState implements ChatComponentState {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _ChatComponentState &&
(identical(other.chatKey, chatKey) || other.chatKey == chatKey) &&
(identical(other.scrollController, scrollController) ||
other.scrollController == scrollController) &&
(identical(other.textEditingController, textEditingController) ||
other.textEditingController == textEditingController) &&
(identical(other.localUser, localUser) ||
other.localUser == localUser) &&
(identical(other.remoteUsers, remoteUsers) ||
@ -246,21 +208,12 @@ class _ChatComponentState implements ChatComponentState {
}
@override
int get hashCode => Object.hash(
runtimeType,
chatKey,
scrollController,
textEditingController,
localUser,
remoteUsers,
historicalRemoteUsers,
unknownUsers,
messageWindow,
title);
int get hashCode => Object.hash(runtimeType, localUser, remoteUsers,
historicalRemoteUsers, unknownUsers, messageWindow, title);
@override
String toString() {
return 'ChatComponentState(chatKey: $chatKey, scrollController: $scrollController, textEditingController: $textEditingController, localUser: $localUser, remoteUsers: $remoteUsers, historicalRemoteUsers: $historicalRemoteUsers, unknownUsers: $unknownUsers, messageWindow: $messageWindow, title: $title)';
return 'ChatComponentState(localUser: $localUser, remoteUsers: $remoteUsers, historicalRemoteUsers: $historicalRemoteUsers, unknownUsers: $unknownUsers, messageWindow: $messageWindow, title: $title)';
}
}
@ -273,16 +226,15 @@ abstract mixin class _$ChatComponentStateCopyWith<$Res>
@override
@useResult
$Res call(
{GlobalKey<ChatState> chatKey,
AutoScrollController scrollController,
InputTextFieldController textEditingController,
User? localUser,
IMap<Typed<FixedEncodedString43>, User> remoteUsers,
IMap<Typed<FixedEncodedString43>, User> historicalRemoteUsers,
IMap<Typed<FixedEncodedString43>, User> unknownUsers,
{User? localUser,
IMap<String, User> remoteUsers,
IMap<String, User> historicalRemoteUsers,
IMap<String, User> unknownUsers,
AsyncValue<WindowState<Message>> messageWindow,
String title});
@override
$UserCopyWith<$Res>? get localUser;
@override
$AsyncValueCopyWith<WindowState<Message>, $Res> get messageWindow;
}
@ -300,9 +252,6 @@ class __$ChatComponentStateCopyWithImpl<$Res>
@override
@pragma('vm:prefer-inline')
$Res call({
Object? chatKey = null,
Object? scrollController = null,
Object? textEditingController = null,
Object? localUser = freezed,
Object? remoteUsers = null,
Object? historicalRemoteUsers = null,
@ -311,18 +260,6 @@ class __$ChatComponentStateCopyWithImpl<$Res>
Object? title = null,
}) {
return _then(_ChatComponentState(
chatKey: null == chatKey
? _self.chatKey
: chatKey // ignore: cast_nullable_to_non_nullable
as GlobalKey<ChatState>,
scrollController: null == scrollController
? _self.scrollController
: scrollController // ignore: cast_nullable_to_non_nullable
as AutoScrollController,
textEditingController: null == textEditingController
? _self.textEditingController
: textEditingController // ignore: cast_nullable_to_non_nullable
as InputTextFieldController,
localUser: freezed == localUser
? _self.localUser
: localUser // ignore: cast_nullable_to_non_nullable
@ -330,15 +267,15 @@ class __$ChatComponentStateCopyWithImpl<$Res>
remoteUsers: null == remoteUsers
? _self.remoteUsers
: remoteUsers // ignore: cast_nullable_to_non_nullable
as IMap<Typed<FixedEncodedString43>, User>,
as IMap<String, User>,
historicalRemoteUsers: null == historicalRemoteUsers
? _self.historicalRemoteUsers
: historicalRemoteUsers // ignore: cast_nullable_to_non_nullable
as IMap<Typed<FixedEncodedString43>, User>,
as IMap<String, User>,
unknownUsers: null == unknownUsers
? _self.unknownUsers
: unknownUsers // ignore: cast_nullable_to_non_nullable
as IMap<Typed<FixedEncodedString43>, User>,
as IMap<String, User>,
messageWindow: null == messageWindow
? _self.messageWindow
: messageWindow // ignore: cast_nullable_to_non_nullable
@ -350,6 +287,20 @@ class __$ChatComponentStateCopyWithImpl<$Res>
));
}
/// Create a copy of ChatComponentState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$UserCopyWith<$Res>? get localUser {
if (_self.localUser == null) {
return null;
}
return $UserCopyWith<$Res>(_self.localUser!, (value) {
return _then(_self.copyWith(localUser: value));
});
}
/// Create a copy of ChatComponentState
/// with the given fields replaced by the non-null parameter values.
@override

View file

@ -25,7 +25,10 @@ enum MessageSendState {
@freezed
sealed class MessageState with _$MessageState {
@JsonSerializable()
const factory MessageState({
// Sequence number of the message for display purposes
required int seqId,
// Content of the message
@JsonKey(fromJson: messageFromJson, toJson: messageToJson)
required proto.Message content,

View file

@ -15,7 +15,8 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$MessageState implements DiagnosticableTreeMixin {
// Content of the message
// Sequence number of the message for display purposes
int get seqId; // Content of the message
@JsonKey(fromJson: messageFromJson, toJson: messageToJson)
proto.Message get content; // Sent timestamp
Timestamp get sentTimestamp; // Reconciled timestamp
@ -37,6 +38,7 @@ mixin _$MessageState implements DiagnosticableTreeMixin {
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'MessageState'))
..add(DiagnosticsProperty('seqId', seqId))
..add(DiagnosticsProperty('content', content))
..add(DiagnosticsProperty('sentTimestamp', sentTimestamp))
..add(DiagnosticsProperty('reconciledTimestamp', reconciledTimestamp))
@ -48,6 +50,7 @@ mixin _$MessageState implements DiagnosticableTreeMixin {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is MessageState &&
(identical(other.seqId, seqId) || other.seqId == seqId) &&
(identical(other.content, content) || other.content == content) &&
(identical(other.sentTimestamp, sentTimestamp) ||
other.sentTimestamp == sentTimestamp) &&
@ -59,12 +62,12 @@ mixin _$MessageState implements DiagnosticableTreeMixin {
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType, content, sentTimestamp, reconciledTimestamp, sendState);
int get hashCode => Object.hash(runtimeType, seqId, content, sentTimestamp,
reconciledTimestamp, sendState);
@override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
return 'MessageState(content: $content, sentTimestamp: $sentTimestamp, reconciledTimestamp: $reconciledTimestamp, sendState: $sendState)';
return 'MessageState(seqId: $seqId, content: $content, sentTimestamp: $sentTimestamp, reconciledTimestamp: $reconciledTimestamp, sendState: $sendState)';
}
}
@ -75,7 +78,8 @@ abstract mixin class $MessageStateCopyWith<$Res> {
_$MessageStateCopyWithImpl;
@useResult
$Res call(
{@JsonKey(fromJson: messageFromJson, toJson: messageToJson)
{int seqId,
@JsonKey(fromJson: messageFromJson, toJson: messageToJson)
proto.Message content,
Timestamp sentTimestamp,
Timestamp? reconciledTimestamp,
@ -94,12 +98,17 @@ class _$MessageStateCopyWithImpl<$Res> implements $MessageStateCopyWith<$Res> {
@pragma('vm:prefer-inline')
@override
$Res call({
Object? seqId = null,
Object? content = null,
Object? sentTimestamp = null,
Object? reconciledTimestamp = freezed,
Object? sendState = freezed,
}) {
return _then(_self.copyWith(
seqId: null == seqId
? _self.seqId
: seqId // ignore: cast_nullable_to_non_nullable
as int,
content: null == content
? _self.content
: content // ignore: cast_nullable_to_non_nullable
@ -121,10 +130,12 @@ class _$MessageStateCopyWithImpl<$Res> implements $MessageStateCopyWith<$Res> {
}
/// @nodoc
@JsonSerializable()
class _MessageState with DiagnosticableTreeMixin implements MessageState {
const _MessageState(
{@JsonKey(fromJson: messageFromJson, toJson: messageToJson)
{required this.seqId,
@JsonKey(fromJson: messageFromJson, toJson: messageToJson)
required this.content,
required this.sentTimestamp,
required this.reconciledTimestamp,
@ -132,6 +143,9 @@ class _MessageState with DiagnosticableTreeMixin implements MessageState {
factory _MessageState.fromJson(Map<String, dynamic> json) =>
_$MessageStateFromJson(json);
// Sequence number of the message for display purposes
@override
final int seqId;
// Content of the message
@override
@JsonKey(fromJson: messageFromJson, toJson: messageToJson)
@ -165,6 +179,7 @@ class _MessageState with DiagnosticableTreeMixin implements MessageState {
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'MessageState'))
..add(DiagnosticsProperty('seqId', seqId))
..add(DiagnosticsProperty('content', content))
..add(DiagnosticsProperty('sentTimestamp', sentTimestamp))
..add(DiagnosticsProperty('reconciledTimestamp', reconciledTimestamp))
@ -176,6 +191,7 @@ class _MessageState with DiagnosticableTreeMixin implements MessageState {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _MessageState &&
(identical(other.seqId, seqId) || other.seqId == seqId) &&
(identical(other.content, content) || other.content == content) &&
(identical(other.sentTimestamp, sentTimestamp) ||
other.sentTimestamp == sentTimestamp) &&
@ -187,12 +203,12 @@ class _MessageState with DiagnosticableTreeMixin implements MessageState {
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType, content, sentTimestamp, reconciledTimestamp, sendState);
int get hashCode => Object.hash(runtimeType, seqId, content, sentTimestamp,
reconciledTimestamp, sendState);
@override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
return 'MessageState(content: $content, sentTimestamp: $sentTimestamp, reconciledTimestamp: $reconciledTimestamp, sendState: $sendState)';
return 'MessageState(seqId: $seqId, content: $content, sentTimestamp: $sentTimestamp, reconciledTimestamp: $reconciledTimestamp, sendState: $sendState)';
}
}
@ -205,7 +221,8 @@ abstract mixin class _$MessageStateCopyWith<$Res>
@override
@useResult
$Res call(
{@JsonKey(fromJson: messageFromJson, toJson: messageToJson)
{int seqId,
@JsonKey(fromJson: messageFromJson, toJson: messageToJson)
proto.Message content,
Timestamp sentTimestamp,
Timestamp? reconciledTimestamp,
@ -225,12 +242,17 @@ class __$MessageStateCopyWithImpl<$Res>
@override
@pragma('vm:prefer-inline')
$Res call({
Object? seqId = null,
Object? content = null,
Object? sentTimestamp = null,
Object? reconciledTimestamp = freezed,
Object? sendState = freezed,
}) {
return _then(_MessageState(
seqId: null == seqId
? _self.seqId
: seqId // ignore: cast_nullable_to_non_nullable
as int,
content: null == content
? _self.content
: content // ignore: cast_nullable_to_non_nullable

View file

@ -8,6 +8,7 @@ part of 'message_state.dart';
_MessageState _$MessageStateFromJson(Map<String, dynamic> json) =>
_MessageState(
seqId: (json['seq_id'] as num).toInt(),
content: messageFromJson(json['content'] as Map<String, dynamic>),
sentTimestamp: Timestamp.fromJson(json['sent_timestamp']),
reconciledTimestamp: json['reconciled_timestamp'] == null
@ -20,6 +21,7 @@ _MessageState _$MessageStateFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$MessageStateToJson(_MessageState instance) =>
<String, dynamic>{
'seq_id': instance.seqId,
'content': messageToJson(instance.content),
'sent_timestamp': instance.sentTimestamp.toJson(),
'reconciled_timestamp': instance.reconciledTimestamp?.toJson(),

View file

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

View file

@ -0,0 +1,431 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
// Typedefs need to come out
// ignore: implementation_imports
import 'package:flutter_chat_ui/src/utils/typedefs.dart';
import 'package:provider/provider.dart';
import '../../../theme/theme.dart';
import '../../chat.dart';
enum ShiftEnterAction { newline, send }
/// The message composer widget positioned at the bottom of the chat screen.
///
/// Includes a text input field, an optional attachment button,
/// and a send button.
class VcComposerWidget extends StatefulWidget {
/// Creates a message composer widget.
const VcComposerWidget({
super.key,
this.textEditingController,
this.left = 0,
this.right = 0,
this.top,
this.bottom = 0,
this.sigmaX = 20,
this.sigmaY = 20,
this.padding = const EdgeInsets.all(8),
this.attachmentIcon = const Icon(Icons.attachment),
this.sendIcon = const Icon(Icons.send),
this.gap = 8,
this.inputBorder,
this.filled,
this.topWidget,
this.handleSafeArea = true,
this.backgroundColor,
this.attachmentIconColor,
this.sendIconColor,
this.hintColor,
this.textColor,
this.inputFillColor,
this.hintText = 'Type a message',
this.keyboardAppearance,
this.autocorrect,
this.autofocus = false,
this.textCapitalization = TextCapitalization.sentences,
this.keyboardType,
this.textInputAction = TextInputAction.newline,
this.shiftEnterAction = ShiftEnterAction.send,
this.focusNode,
this.maxLength,
this.minLines = 1,
this.maxLines = 3,
});
/// Optional controller for the text input field.
final TextEditingController? textEditingController;
/// Optional left position.
final double? left;
/// Optional right position.
final double? right;
/// Optional top position.
final double? top;
/// Optional bottom position.
final double? bottom;
/// Optional X blur value for the background (if using glassmorphism).
final double? sigmaX;
/// Optional Y blur value for the background (if using glassmorphism).
final double? sigmaY;
/// Padding around the composer content.
final EdgeInsetsGeometry? padding;
/// Icon for the attachment button. Defaults to [Icons.attachment].
final Widget? attachmentIcon;
/// Icon for the send button. Defaults to [Icons.send].
final Widget? sendIcon;
/// Horizontal gap between elements (attachment icon, text field, send icon).
final double? gap;
/// Border style for the text input field.
final InputBorder? inputBorder;
/// Whether the text input field should be filled.
final bool? filled;
/// Optional widget to display above the main composer row.
final Widget? topWidget;
/// Whether to adjust padding for the bottom safe area.
final bool handleSafeArea;
/// Background color of the composer container.
final Color? backgroundColor;
/// Color of the attachment icon.
final Color? attachmentIconColor;
/// Color of the send icon.
final Color? sendIconColor;
/// Color of the hint text in the input field.
final Color? hintColor;
/// Color of the text entered in the input field.
final Color? textColor;
/// Fill color for the text input field when [filled] is true.
final Color? inputFillColor;
/// Placeholder text for the input field.
final String? hintText;
/// Appearance of the keyboard.
final Brightness? keyboardAppearance;
/// Whether to enable autocorrect for the input field.
final bool? autocorrect;
/// Whether the input field should autofocus.
final bool autofocus;
/// Capitalization behavior for the input field.
final TextCapitalization textCapitalization;
/// Type of keyboard to display.
final TextInputType? keyboardType;
/// Action button type for the keyboard (e.g., newline, send).
final TextInputAction textInputAction;
/// Action when shift-enter is pressed (e.g., newline, send).
final ShiftEnterAction shiftEnterAction;
/// Focus node for the text input field.
final FocusNode? focusNode;
/// Maximum character length for the input field.
final int? maxLength;
/// Minimum number of lines for the input field.
final int? minLines;
/// Maximum number of lines the input field can expand to.
final int? maxLines;
@override
State<VcComposerWidget> createState() => _VcComposerState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(DiagnosticsProperty<TextEditingController?>(
'textEditingController', textEditingController))
..add(DoubleProperty('left', left))
..add(DoubleProperty('right', right))
..add(DoubleProperty('top', top))
..add(DoubleProperty('bottom', bottom))
..add(DoubleProperty('sigmaX', sigmaX))
..add(DoubleProperty('sigmaY', sigmaY))
..add(DiagnosticsProperty<EdgeInsetsGeometry?>('padding', padding))
..add(DoubleProperty('gap', gap))
..add(DiagnosticsProperty<InputBorder?>('inputBorder', inputBorder))
..add(DiagnosticsProperty<bool?>('filled', filled))
..add(DiagnosticsProperty<bool>('handleSafeArea', handleSafeArea))
..add(ColorProperty('backgroundColor', backgroundColor))
..add(ColorProperty('attachmentIconColor', attachmentIconColor))
..add(ColorProperty('sendIconColor', sendIconColor))
..add(ColorProperty('hintColor', hintColor))
..add(ColorProperty('textColor', textColor))
..add(ColorProperty('inputFillColor', inputFillColor))
..add(StringProperty('hintText', hintText))
..add(EnumProperty<Brightness?>('keyboardAppearance', keyboardAppearance))
..add(DiagnosticsProperty<bool?>('autocorrect', autocorrect))
..add(DiagnosticsProperty<bool>('autofocus', autofocus))
..add(EnumProperty<TextCapitalization>(
'textCapitalization', textCapitalization))
..add(DiagnosticsProperty<TextInputType?>('keyboardType', keyboardType))
..add(EnumProperty<TextInputAction>('textInputAction', textInputAction))
..add(
EnumProperty<ShiftEnterAction>('shiftEnterAction', shiftEnterAction))
..add(DiagnosticsProperty<FocusNode?>('focusNode', focusNode))
..add(IntProperty('maxLength', maxLength))
..add(IntProperty('minLines', minLines))
..add(IntProperty('maxLines', maxLines));
}
}
class _VcComposerState extends State<VcComposerWidget> {
final _key = GlobalKey();
late final TextEditingController _textController;
late final FocusNode _focusNode;
late String _suffixText;
@override
void initState() {
super.initState();
_textController = widget.textEditingController ?? TextEditingController();
_focusNode = widget.focusNode ?? FocusNode();
_focusNode.onKeyEvent = _handleKeyEvent;
_updateSuffixText();
WidgetsBinding.instance.addPostFrameCallback((_) => _measure());
}
void _updateSuffixText() {
final utf8Length = utf8.encode(_textController.text).length;
_suffixText = '$utf8Length/${widget.maxLength}';
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
// Check for Shift+Enter
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.enter &&
HardwareKeyboard.instance.isShiftPressed) {
if (widget.shiftEnterAction == ShiftEnterAction.send) {
_handleSubmitted(_textController.text);
return KeyEventResult.handled;
} else if (widget.shiftEnterAction == ShiftEnterAction.newline) {
final val = _textController.value;
final insertOffset = val.selection.extent.offset;
final messageWithNewLine =
'${_textController.text.substring(0, insertOffset)}\n'
'${_textController.text.substring(insertOffset)}';
_textController.value = TextEditingValue(
text: messageWithNewLine,
selection: TextSelection.fromPosition(
TextPosition(offset: insertOffset + 1),
),
);
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
}
@override
void didUpdateWidget(covariant VcComposerWidget oldWidget) {
super.didUpdateWidget(oldWidget);
WidgetsBinding.instance.addPostFrameCallback((_) => _measure());
}
@override
void dispose() {
// Only try to dispose text controller if it's not provided, let
// user handle disposing it how they want.
if (widget.textEditingController == null) {
_textController.dispose();
}
if (widget.focusNode == null) {
_focusNode.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final bottomSafeArea =
widget.handleSafeArea ? MediaQuery.of(context).padding.bottom : 0.0;
final onAttachmentTap = context.read<OnAttachmentTapCallback?>();
final theme = Theme.of(context);
final scaleTheme = theme.extension<ScaleTheme>()!;
final config = scaleTheme.config;
final scheme = scaleTheme.scheme;
final scale = scaleTheme.scheme.scale(ScaleKind.primary);
final textTheme = theme.textTheme;
final scaleChatTheme = scaleTheme.chatTheme();
final chatTheme = scaleChatTheme.chatTheme;
final suffixTextStyle =
textTheme.bodySmall!.copyWith(color: scale.subtleText);
return Positioned(
left: widget.left,
right: widget.right,
top: widget.top,
bottom: widget.bottom,
child: ClipRect(
child: DecoratedBox(
key: _key,
decoration: BoxDecoration(
border: config.preferBorders
? Border(top: BorderSide(color: scale.border, width: 2))
: null,
color: config.preferBorders
? scale.elementBackground
: scale.border),
child: Column(
children: [
if (widget.topWidget != null) widget.topWidget!,
Padding(
padding: widget.handleSafeArea
? (widget.padding?.add(
EdgeInsets.only(bottom: bottomSafeArea),
) ??
EdgeInsets.only(bottom: bottomSafeArea))
: (widget.padding ?? EdgeInsets.zero),
child: Row(
children: [
if (widget.attachmentIcon != null &&
onAttachmentTap != null)
IconButton(
icon: widget.attachmentIcon!,
color: widget.attachmentIconColor ??
chatTheme.colors.onSurface.withValues(alpha: 0.5),
onPressed: onAttachmentTap,
)
else
const SizedBox.shrink(),
SizedBox(width: widget.gap),
Expanded(
child: TextField(
controller: _textController,
decoration: InputDecoration(
filled: widget.filled ?? !config.preferBorders,
fillColor: widget.inputFillColor ??
scheme.primaryScale.subtleBackground,
isDense: true,
contentPadding:
const EdgeInsets.fromLTRB(8, 8, 8, 8),
disabledBorder: OutlineInputBorder(
borderSide: config.preferBorders
? BorderSide(
color: scheme.grayScale.border,
width: 2)
: BorderSide.none,
borderRadius: BorderRadius.all(Radius.circular(
8 * config.borderRadiusScale))),
enabledBorder: OutlineInputBorder(
borderSide: config.preferBorders
? BorderSide(
color: scheme.primaryScale.border,
width: 2)
: BorderSide.none,
borderRadius: BorderRadius.all(Radius.circular(
8 * config.borderRadiusScale))),
focusedBorder: OutlineInputBorder(
borderSide: config.preferBorders
? BorderSide(
color: scheme.primaryScale.border,
width: 2)
: BorderSide.none,
borderRadius: BorderRadius.all(Radius.circular(
8 * config.borderRadiusScale))),
hintText: widget.hintText,
hintStyle: chatTheme.typography.bodyMedium.copyWith(
color: widget.hintColor ??
chatTheme.colors.onSurface
.withValues(alpha: 0.5),
),
border: widget.inputBorder,
hoverColor: Colors.transparent,
suffix: Text(_suffixText, style: suffixTextStyle)),
onSubmitted: _handleSubmitted,
onChanged: (value) {
setState(_updateSuffixText);
},
textInputAction: widget.textInputAction,
keyboardAppearance: widget.keyboardAppearance,
autocorrect: widget.autocorrect ?? true,
autofocus: widget.autofocus,
textCapitalization: widget.textCapitalization,
keyboardType: widget.keyboardType,
focusNode: _focusNode,
//maxLength: widget.maxLength,
minLines: widget.minLines,
maxLines: widget.maxLines,
maxLengthEnforcement: MaxLengthEnforcement.none,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(
maxLength: widget.maxLength),
],
),
),
SizedBox(width: widget.gap),
if ((widget.sendIcon ?? scaleChatTheme.sendButtonIcon) !=
null)
IconButton(
icon:
(widget.sendIcon ?? scaleChatTheme.sendButtonIcon)!,
color: widget.sendIconColor,
onPressed: () => _handleSubmitted(_textController.text),
)
else
const SizedBox.shrink(),
],
),
),
],
),
),
),
);
}
void _measure() {
if (!mounted) {
return;
}
final renderBox = _key.currentContext?.findRenderObject() as RenderBox?;
if (renderBox != null) {
final height = renderBox.size.height;
final bottomSafeArea = MediaQuery.of(context).padding.bottom;
context.read<ComposerHeightNotifier>().setHeight(
// only set real height of the composer, ignoring safe area
widget.handleSafeArea ? height - bottomSafeArea : height,
);
}
}
void _handleSubmitted(String text) {
if (text.isNotEmpty) {
context.read<OnMessageSendCallback?>()?.call(text);
_textController.clear();
}
}
}

View file

@ -0,0 +1,269 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:provider/provider.dart';
import '../../../theme/theme.dart';
import '../date_formatter.dart';
/// A widget that displays a text message.
class VcTextMessageWidget extends StatelessWidget {
/// Creates a widget to display a simple text message.
const VcTextMessageWidget({
required this.message,
required this.index,
this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
this.borderRadius,
this.onlyEmojiFontSize,
this.sentBackgroundColor,
this.receivedBackgroundColor,
this.sentTextStyle,
this.receivedTextStyle,
this.timeStyle,
this.showTime = true,
this.showStatus = true,
this.timeAndStatusPosition = TimeAndStatusPosition.end,
super.key,
});
/// The text message data model.
final TextMessage message;
/// The index of the message in the list.
final int index;
/// Padding around the message bubble content.
final EdgeInsetsGeometry? padding;
/// Border radius of the message bubble.
final BorderRadiusGeometry? borderRadius;
/// Font size for messages containing only emojis.
final double? onlyEmojiFontSize;
/// Background color for messages sent by the current user.
final Color? sentBackgroundColor;
/// Background color for messages received from other users.
final Color? receivedBackgroundColor;
/// Text style for messages sent by the current user.
final TextStyle? sentTextStyle;
/// Text style for messages received from other users.
final TextStyle? receivedTextStyle;
/// Text style for the message timestamp and status.
final TextStyle? timeStyle;
/// Whether to display the message timestamp.
final bool showTime;
/// Whether to display the message status (sent, delivered, seen)
/// for sent messages.
final bool showStatus;
/// Position of the timestamp and status indicator relative to the text.
final TimeAndStatusPosition timeAndStatusPosition;
bool get _isOnlyEmoji => message.metadata?['isOnlyEmoji'] == true;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scaleTheme = theme.extension<ScaleTheme>()!;
final config = scaleTheme.config;
final scheme = scaleTheme.scheme;
final scale = scaleTheme.scheme.scale(ScaleKind.primary);
final textTheme = theme.textTheme;
final scaleChatTheme = scaleTheme.chatTheme();
final chatTheme = scaleChatTheme.chatTheme;
final isSentByMe = context.watch<UserID>() == message.authorId;
final backgroundColor = _resolveBackgroundColor(isSentByMe, scaleChatTheme);
final textStyle = _resolveTextStyle(isSentByMe, scaleChatTheme);
final timeStyle = _resolveTimeStyle(isSentByMe, scaleChatTheme);
final emojiFontSize = onlyEmojiFontSize ?? scaleChatTheme.onlyEmojiFontSize;
final timeAndStatus = showTime || (isSentByMe && showStatus)
? TimeAndStatus(
time: message.time,
status: message.status,
showTime: showTime,
showStatus: isSentByMe && showStatus,
textStyle: timeStyle,
)
: null;
final textContent = Text(
message.text,
style: _isOnlyEmoji
? textStyle.copyWith(fontSize: emojiFontSize)
: textStyle,
);
return Container(
padding: _isOnlyEmoji
? EdgeInsets.symmetric(
horizontal: (padding?.horizontal ?? 0) / 2,
// vertical: 0,
)
: padding,
decoration: _isOnlyEmoji
? null
: BoxDecoration(
color: backgroundColor,
borderRadius: borderRadius ?? chatTheme.shape,
),
child: _buildContentBasedOnPosition(
context: context,
textContent: textContent,
timeAndStatus: timeAndStatus,
textStyle: textStyle,
),
);
}
Widget _buildContentBasedOnPosition({
required BuildContext context,
required Widget textContent,
TimeAndStatus? timeAndStatus,
TextStyle? textStyle,
}) {
if (timeAndStatus == null) {
return textContent;
}
switch (timeAndStatusPosition) {
case TimeAndStatusPosition.start:
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [textContent, timeAndStatus],
);
case TimeAndStatusPosition.inline:
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(child: textContent),
const SizedBox(width: 4),
timeAndStatus,
],
);
case TimeAndStatusPosition.end:
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [textContent, timeAndStatus],
);
}
}
Color _resolveBackgroundColor(bool isSentByMe, ScaleChatTheme theme) {
if (isSentByMe) {
return sentBackgroundColor ?? theme.primaryColor;
}
return receivedBackgroundColor ?? theme.secondaryColor;
}
TextStyle _resolveTextStyle(bool isSentByMe, ScaleChatTheme theme) {
if (isSentByMe) {
return sentTextStyle ?? theme.sentMessageBodyTextStyle;
}
return receivedTextStyle ?? theme.receivedMessageBodyTextStyle;
}
TextStyle _resolveTimeStyle(bool isSentByMe, ScaleChatTheme theme) {
final ts = _resolveTextStyle(isSentByMe, theme);
return theme.timeStyle.copyWith(color: ts.color);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(DiagnosticsProperty<TextMessage>('message', message))
..add(IntProperty('index', index))
..add(DiagnosticsProperty<EdgeInsetsGeometry?>('padding', padding))
..add(DiagnosticsProperty<BorderRadiusGeometry?>(
'borderRadius', borderRadius))
..add(DoubleProperty('onlyEmojiFontSize', onlyEmojiFontSize))
..add(ColorProperty('sentBackgroundColor', sentBackgroundColor))
..add(ColorProperty('receivedBackgroundColor', receivedBackgroundColor))
..add(DiagnosticsProperty<TextStyle?>('sentTextStyle', sentTextStyle))
..add(DiagnosticsProperty<TextStyle?>(
'receivedTextStyle', receivedTextStyle))
..add(DiagnosticsProperty<TextStyle?>('timeStyle', timeStyle))
..add(DiagnosticsProperty<bool>('showTime', showTime))
..add(DiagnosticsProperty<bool>('showStatus', showStatus))
..add(EnumProperty<TimeAndStatusPosition>(
'timeAndStatusPosition', timeAndStatusPosition));
}
}
/// A widget to display the message timestamp and status indicator.
class TimeAndStatus extends StatelessWidget {
/// Creates a widget for displaying time and status.
const TimeAndStatus({
required this.time,
this.status,
this.showTime = true,
this.showStatus = true,
this.textStyle,
super.key,
});
/// The time the message was created.
final DateTime? time;
/// The status of the message.
final MessageStatus? status;
/// Whether to display the timestamp.
final bool showTime;
/// Whether to display the status indicator.
final bool showStatus;
/// The text style for the time and status.
final TextStyle? textStyle;
@override
Widget build(BuildContext context) {
final dformat = DateFormatter();
return Row(
spacing: 2,
mainAxisSize: MainAxisSize.min,
children: [
if (showTime && time != null)
Text(dformat.chatDateTimeFormat(time!.toLocal()), style: textStyle),
if (showStatus && status != null)
if (status == MessageStatus.sending)
SizedBox(
width: 6,
height: 6,
child: CircularProgressIndicator(
color: textStyle?.color,
strokeWidth: 2,
),
)
else
Icon(getIconForStatus(status!), color: textStyle?.color, size: 12),
],
);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(DiagnosticsProperty<DateTime?>('time', time))
..add(EnumProperty<MessageStatus?>('status', status))
..add(DiagnosticsProperty<bool>('showTime', showTime))
..add(DiagnosticsProperty<bool>('showStatus', showStatus))
..add(DiagnosticsProperty<TextStyle?>('textStyle', textStyle));
}
}

View file

@ -1,11 +1,13 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:async_tools/async_tools.dart';
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_core/flutter_chat_core.dart' as core;
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:veilid_support/veilid_support.dart';
@ -16,11 +18,15 @@ import '../../conversation/conversation.dart';
import '../../notifications/notifications.dart';
import '../../theme/theme.dart';
import '../chat.dart';
import 'chat_builders/chat_builders.dart';
const onEndReachedThreshold = 0.75;
const _kScrollTag = 'kScrollTag';
const kSeqId = 'seqId';
const maxMessageLength = 2048;
class ChatComponentWidget extends StatelessWidget {
const ChatComponentWidget({
class ChatComponentWidget extends StatefulWidget {
const ChatComponentWidget._({
required super.key,
required TypedKey localConversationRecordKey,
required void Function() onCancel,
@ -29,10 +35,14 @@ class ChatComponentWidget extends StatelessWidget {
_onCancel = onCancel,
_onClose = onClose;
/////////////////////////////////////////////////////////////////////
@override
Widget build(BuildContext context) {
// Create a single-contact chat and its associated state
static Widget singleContact({
required BuildContext context,
required TypedKey localConversationRecordKey,
required void Function() onCancel,
required void Function() onClose,
Key? key,
}) {
// Get the account info
final accountInfo = context.watch<AccountInfoCubit>().state;
@ -45,19 +55,19 @@ class ChatComponentWidget extends StatelessWidget {
// Get the active conversation cubit
final activeConversationCubit = context
.select<ActiveConversationsBlocMapCubit, ActiveConversationCubit?>(
(x) => x.tryOperateSync(_localConversationRecordKey,
(x) => x.tryOperateSync(localConversationRecordKey,
closure: (cubit) => cubit));
if (activeConversationCubit == null) {
return waitingPage(onCancel: _onCancel);
return waitingPage(onCancel: onCancel);
}
// Get the messages cubit
final messagesCubit = context.select<ActiveSingleContactChatBlocMapCubit,
SingleContactMessagesCubit?>(
(x) => x.tryOperateSync(_localConversationRecordKey,
(x) => x.tryOperateSync(localConversationRecordKey,
closure: (cubit) => cubit));
if (messagesCubit == null) {
return waitingPage(onCancel: _onCancel);
return waitingPage(onCancel: onCancel);
}
// Make chat component state
@ -70,26 +80,65 @@ class ChatComponentWidget extends StatelessWidget {
activeConversationCubit: activeConversationCubit,
messagesCubit: messagesCubit,
),
child: Builder(builder: _buildChatComponent));
child: ChatComponentWidget._(
key: ValueKey(localConversationRecordKey),
localConversationRecordKey: localConversationRecordKey,
onCancel: onCancel,
onClose: onClose));
}
/////////////////////////////////////////////////////////////////////
@override
State<ChatComponentWidget> createState() => _ChatComponentWidgetState();
Widget _buildChatComponent(BuildContext context) {
////////////////////////////////////////////////////////////////////////////
final TypedKey _localConversationRecordKey;
final void Function() _onCancel;
final void Function() _onClose;
}
class _ChatComponentWidgetState extends State<ChatComponentWidget> {
////////////////////////////////////////////////////////////////////
@override
void initState() {
_chatController = core.InMemoryChatController();
_textEditingController = TextEditingController();
_scrollController = ScrollController();
_chatStateProcessor = SingleStateProcessor<ChatComponentState>();
final _chatComponentCubit = context.read<ChatComponentCubit>();
_chatStateProcessor.follow(_chatComponentCubit.stream,
_chatComponentCubit.state, _updateChatState);
super.initState();
}
@override
void dispose() {
unawaited(_chatStateProcessor.close());
_chatController.dispose();
_scrollController.dispose();
_textEditingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scaleScheme = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
final scale = scaleScheme.scale(ScaleKind.primary);
final scaleTheme = theme.extension<ScaleTheme>()!;
final scale = scaleTheme.scheme.scale(ScaleKind.primary);
final textTheme = theme.textTheme;
final chatTheme = makeChatTheme(scaleScheme, scaleConfig, textTheme);
final errorChatTheme = (ChatThemeEditor(chatTheme)
..inputTextColor = scaleScheme.errorScale.primary
..sendButtonIcon = Image.asset(
'assets/icon-send.png',
color: scaleScheme.errorScale.primary,
package: 'flutter_chat_ui',
))
.commit();
final scaleChatTheme = scaleTheme.chatTheme();
// final errorChatTheme = chatTheme.copyWith(color:)
// ..inputTextColor = scaleScheme.errorScale.primary
// ..sendButtonIcon = Image.asset(
// 'assets/icon-send.png',
// color: scaleScheme.errorScale.primary,
// package: 'flutter_chat_ui',
// ))
// .commit();
// Get the enclosing chat component cubit that contains our state
// (created by ChatComponentWidget.builder())
@ -110,9 +159,8 @@ class ChatComponentWidget extends StatelessWidget {
final title = chatComponentState.title;
if (chatComponentCubit.scrollOffset != 0) {
chatComponentState.scrollController.position.correctPixels(
chatComponentState.scrollController.position.pixels +
chatComponentCubit.scrollOffset);
_scrollController.position.correctPixels(
_scrollController.position.pixels + chatComponentCubit.scrollOffset);
chatComponentCubit.scrollOffset = 0;
}
@ -138,7 +186,7 @@ class ChatComponentWidget extends StatelessWidget {
IconButton(
iconSize: 24,
icon: Icon(Icons.close, color: scale.borderText),
onPressed: _onClose)
onPressed: widget._onClose)
.paddingLTRB(0, 0, 8, 0)
]),
),
@ -164,7 +212,7 @@ class ChatComponentWidget extends StatelessWidget {
chatComponentCubit.scrollOffset = scrollOffset;
//
singleFuture(chatComponentState.chatKey, () async {
singleFuture((chatComponentCubit, _kScrollTag), () async {
await _handlePageForward(
chatComponentCubit, messageWindow, notification);
});
@ -182,7 +230,7 @@ class ChatComponentWidget extends StatelessWidget {
chatComponentCubit.scrollOffset = scrollOffset;
//
singleFuture(chatComponentState.chatKey, () async {
singleFuture((chatComponentCubit, _kScrollTag), () async {
await _handlePageBackward(
chatComponentCubit, messageWindow, notification);
});
@ -190,82 +238,181 @@ class ChatComponentWidget extends StatelessWidget {
return false;
},
child: ValueListenableBuilder(
valueListenable: chatComponentState.textEditingController,
valueListenable: _textEditingController,
builder: (context, textEditingValue, __) {
final messageIsValid =
utf8.encode(textEditingValue.text).lengthInBytes <
2048;
_messageIsValid(textEditingValue.text);
var sendIconColor = scaleTheme.config.preferBorders
? scale.border
: scale.borderText;
if (!messageIsValid ||
_textEditingController.text.isEmpty) {
sendIconColor = sendIconColor.withAlpha(128);
}
return Chat(
key: chatComponentState.chatKey,
theme: messageIsValid ? chatTheme : errorChatTheme,
messages: messageWindow.window.toList(),
scrollToBottomOnSend: isFirstPage,
scrollController: chatComponentState.scrollController,
inputOptions: InputOptions(
inputClearMode: messageIsValid
? InputClearMode.always
: InputClearMode.never,
textEditingController:
chatComponentState.textEditingController),
// isLastPage: isLastPage,
// onEndReached: () async {
// await _handlePageBackward(
// chatComponentCubit, messageWindow);
// },
//onEndReachedThreshold: onEndReachedThreshold,
//onAttachmentPressed: _handleAttachmentPressed,
//onMessageTap: _handleMessageTap,
//onPreviewDataFetched: _handlePreviewDataFetched,
usePreviewData: false, //
onSendPressed: (pt) {
try {
if (!messageIsValid) {
context.read<NotificationsCubit>().error(
text: translate('chat.message_too_long'));
return;
}
_handleSendPressed(chatComponentCubit, pt);
} on FormatException {
context.read<NotificationsCubit>().error(
text: translate('chat.message_too_long'));
currentUserId: localUser.id,
resolveUser: (id) async {
if (id == localUser.id) {
return localUser;
}
return chatComponentState.remoteUsers.get(id);
},
listBottomWidget: messageIsValid
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:
scaleScheme.errorScale.primary))
color: scaleTheme
.scheme.errorScale.primary))
.toCenter(),
//showUserAvatars: false,
//showUserNames: true,
user: localUser,
emptyState: const EmptyChatWidget());
),
),
timeFormat: core.DateFormat.jm(),
);
}))).expanded(),
],
);
}
void _handleSendPressed(
ChatComponentCubit chatComponentCubit, types.PartialText message) {
final text = message.text;
/////////////////////////////////////////////////////////////////////
bool _messageIsValid(String text) =>
utf8.encode(text).lengthInBytes < maxMessageLength;
Future<void> _updateChatState(ChatComponentState chatComponentState) async {
// Update message window state
final data = chatComponentState.messageWindow.asData;
if (data == null) {
await _chatController.setMessages([]);
return;
}
final windowState = data.value;
await _chatController.setMessages(windowState.window.toList());
// final newMessagesSet = windowState.window.toSet();
// final newMessagesById =
// Map.fromEntries(newMessagesSet.map((m) => MapEntry(m.id, m)));
// final newMessagesBySeqId = Map.fromEntries(
// newMessagesSet.map((m) => MapEntry(m.metadata![kSeqId], m)));
// final oldMessagesSet = _chatController.messages.toSet();
// if (oldMessagesSet.isEmpty) {
// await _chatController.setMessages(windowState.window.toList());
// return;
// }
// // See how many messages differ by equality (not identity)
// // If there are more than `replaceAllMessagesThreshold` differences
// // just replace the whole list of messages
// final diffs = newMessagesSet.diffAndIntersect(oldMessagesSet,
// diffThisMinusOther: true, diffOtherMinusThis: true);
// final addedMessages = diffs.diffThisMinusOther!;
// final removedMessages = diffs.diffOtherMinusThis!;
// final replaceAllPaginationLimit = windowState.windowCount / 3;
// if ((addedMessages.length >= replaceAllPaginationLimit) ||
// removedMessages.length >= replaceAllPaginationLimit) {
// await _chatController.setMessages(windowState.window.toList());
// return;
// }
// // Remove messages that are gone, and replace the ones that have changed
// for (final m in removedMessages) {
// final newm = newMessagesById[m.id];
// if (newm != null) {
// await _chatController.updateMessage(m, newm);
// } else {
// final newm = newMessagesBySeqId[m.metadata![kSeqId]];
// if (newm != null) {
// await _chatController.updateMessage(m, newm);
// addedMessages.remove(newm);
// } else {
// await _chatController.removeMessage(m);
// }
// }
// }
// // // Check for append
// if (addedMessages.isNotEmpty) {
// if (_chatController.messages.isNotEmpty &&
// (addedMessages.first.metadata![kSeqId] as int) >
// (_chatController.messages.reversed.last.metadata![kSeqId]
// as int)) {
// await _chatController.insertAllMessages(addedMessages.reversedView,
// index: 0);
// }
// // Check for prepend
// else if (_chatController.messages.isNotEmpty &&
// (addedMessages.last.metadata![kSeqId] as int) <
// (_chatController.messages.reversed.first.metadata![kSeqId]
// as int)) {
// await _chatController.insertAllMessages(
// addedMessages.reversedView,
// );
// }
// // Otherwise just replace
// // xxx could use a better algorithm here to merge added messages in
// else {
// await _chatController.setMessages(windowState.window.toList());
// }
// }
}
void _handleSendPressed(ChatComponentCubit chatComponentCubit, String text) {
if (text.startsWith('/')) {
chatComponentCubit.runCommand(text);
return;
}
chatComponentCubit.sendMessage(message);
if (!_messageIsValid(text)) {
context
.read<NotificationsCubit>()
.error(text: translate('chat.message_too_long'));
return;
}
chatComponentCubit.sendMessage(text: text);
}
// void _handleAttachmentPressed() async {
// //
// }
Future<void> _handlePageForward(
ChatComponentCubit chatComponentCubit,
WindowState<types.Message> messageWindow,
WindowState<core.Message> messageWindow,
ScrollNotification notification) async {
debugPrint(
'_handlePageForward: messagesState.length=${messageWindow.length} '
@ -299,7 +446,7 @@ class ChatComponentWidget extends StatelessWidget {
Future<void> _handlePageBackward(
ChatComponentCubit chatComponentCubit,
WindowState<types.Message> messageWindow,
WindowState<core.Message> messageWindow,
ScrollNotification notification,
) async {
debugPrint(
@ -335,8 +482,8 @@ class ChatComponentWidget extends StatelessWidget {
//chatComponentCubit.scrollOffset = 0;
}
////////////////////////////////////////////////////////////////////////////
final TypedKey _localConversationRecordKey;
final void Function() _onCancel;
final void Function() _onClose;
late final core.ChatController _chatController;
late final TextEditingController _textEditingController;
late final ScrollController _scrollController;
late final SingleStateProcessor<ChatComponentState> _chatStateProcessor;
}

View file

@ -0,0 +1,41 @@
import 'package:flutter_translate/flutter_translate.dart';
import 'package:intl/intl.dart';
class DateFormatter {
DateFormatter();
String chatDateTimeFormat(DateTime dateTime) {
final now = DateTime.now();
final justNow = now.subtract(const Duration(minutes: 1));
final localDateTime = dateTime.toLocal();
if (!localDateTime.difference(justNow).isNegative) {
return translate('date_formatter.just_now');
}
final roughTimeString = DateFormat.jm().format(dateTime);
if (localDateTime.day == now.day &&
localDateTime.month == now.month &&
localDateTime.year == now.year) {
return roughTimeString;
}
final yesterday = now.subtract(const Duration(days: 1));
if (localDateTime.day == yesterday.day &&
localDateTime.month == now.month &&
localDateTime.year == now.year) {
return translate('date_formatter.yesterday');
}
if (now.difference(localDateTime).inDays < 4) {
final weekday = DateFormat(DateFormat.WEEKDAY).format(localDateTime);
return '$weekday, $roughTimeString';
}
return '${DateFormat.yMd().format(dateTime)}, $roughTimeString';
}
}

View file

@ -0,0 +1,54 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
Utf8LengthLimitingTextInputFormatter({this.maxLength})
: assert(maxLength != null || maxLength! >= 0, 'maxLength is invalid');
final int? maxLength;
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
if (maxLength != null && _bytesLength(newValue.text) > maxLength!) {
// If already at the maximum and tried to enter even more,
// keep the old value.
if (_bytesLength(oldValue.text) == maxLength) {
return oldValue;
}
return _truncate(newValue, maxLength!);
}
return newValue;
}
static TextEditingValue _truncate(TextEditingValue value, int maxLength) {
var newValue = '';
if (_bytesLength(value.text) > maxLength) {
var length = 0;
value.text.characters.takeWhile((char) {
final nbBytes = _bytesLength(char);
if (length + nbBytes <= maxLength) {
newValue += char;
length += nbBytes;
return true;
}
return false;
});
}
return TextEditingValue(
text: newValue,
selection: value.selection.copyWith(
baseOffset: min(value.selection.start, newValue.length),
extentOffset: min(value.selection.end, newValue.length),
),
);
}
static int _bytesLength(String value) => utf8.encode(value).length;
}

View file

@ -1,3 +1,4 @@
export 'chat_component_widget.dart';
export 'empty_chat_widget.dart';
export 'no_conversation_widget.dart';
export 'utf8_length_limiting_text_input_formatter.dart';

View file

@ -17,8 +17,12 @@ class VeilidChatGlobalInit {
// Initialize Veilid
Future<void> _initializeVeilid() async {
// Init Veilid
try {
Veilid.instance.initializeVeilidCore(
await getDefaultVeilidPlatformConfig(kIsWeb, VeilidChatApp.name));
} on VeilidAPIExceptionAlreadyInitialized {
log.debug('Already initialized, not reinitializing veilid-core');
}
// Veilid logging
initVeilidLog(kIsDebugMode);

View file

@ -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, Intent>{
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: <Type, Action<Intent>>{
ReloadThemeIntent: CallbackAction<ReloadThemeIntent>(
onInvoke: (intent) => reloadTheme(context)),
ChangeBrightnessIntent: CallbackAction<ChangeBrightnessIntent>(
onInvoke: (intent) => changeBrightness(context)),
ChangeColorIntent: CallbackAction<ChangeColorIntent>(
onInvoke: (intent) => changeColor(context)),
AttachDetachIntent: CallbackAction<AttachDetachIntent>(
onInvoke: (intent) => _attachDetach(context)),
DeveloperPageIntent: CallbackAction<DeveloperPageIntent>(

View file

@ -130,7 +130,11 @@ class _HomeAccountReadyState extends State<HomeAccountReady> {
if (activeChatLocalConversationKey == null) {
return const NoConversationWidget();
}
return ChatComponentWidget(
return Material(
color: Colors.transparent,
child: Builder(
builder: (context) => ChatComponentWidget.singleContact(
context: context,
localConversationRecordKey: activeChatLocalConversationKey,
onCancel: () {
activeChatCubit.setActiveChat(null);
@ -138,7 +142,7 @@ class _HomeAccountReadyState extends State<HomeAccountReady> {
onClose: () {
activeChatCubit.setActiveChat(null);
},
key: ValueKey(activeChatLocalConversationKey));
key: ValueKey(activeChatLocalConversationKey))));
}
@override

View file

@ -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,6 +208,8 @@ class HomeScreenState extends State<HomeScreen>
return DefaultTextStyle(
style: theme.textTheme.bodySmall!,
child: KeyboardAvoider(
curve: Curves.ease,
child: ZoomDrawer(
controller: _zoomDrawerController,
menuScreen: Builder(builder: (context) {
@ -223,17 +226,14 @@ class HomeScreenState extends State<HomeScreen>
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),
));
)));
}
////////////////////////////////////////////////////////////////////////////

View file

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

View file

@ -1,4 +1,3 @@
export 'chat_theme.dart';
export 'radix_generator.dart';
export 'scale_theme/scale_theme.dart';
export 'theme_preference.dart';

View file

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

View file

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

View file

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

View file

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

View file

@ -128,7 +128,7 @@ Future<void> showErrorStacktraceModal(
await showErrorModal(
context: context,
title: translate('toast.error'),
text: 'Error: {e}\n StackTrace: {st}',
text: 'Error: $error\n StackTrace: $stackTrace',
);
}

View file

@ -165,5 +165,8 @@ void initLoggy() {
registerVeilidProtoToDebug();
registerVeilidDHTProtoToDebug();
registerVeilidchatProtoToDebug();
if (kIsDebugMode) {
Bloc.observer = const StateLogger();
}
}

View file

@ -6,14 +6,17 @@ import 'package:veilid_support/veilid_support.dart';
import 'loggy.dart';
const Map<String, LogLevel> _blocChangeLogLevels = {
'ConnectionStateCubit': LogLevel.off,
'ActiveSingleContactChatBlocMapCubit': LogLevel.off,
'ActiveConversationsBlocMapCubit': LogLevel.off,
'PersistentQueueCubit<Message>': LogLevel.off,
'TableDBArrayProtobufCubit<ReconciledMessage>': LogLevel.off,
'DHTLogCubit<Message>': 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<Conversation>': LogLevel.debug,
};
const Map<String, LogLevel> _blocCreateCloseLogLevels = {};
@ -40,7 +43,7 @@ class StateLogger extends BlocObserver {
@override
void onChange(BlocBase<dynamic> bloc, Change<dynamic> 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,

View file

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

View file

@ -169,6 +169,7 @@ class _$SuperIdentityCopyWithImpl<$Res>
}
/// @nodoc
@JsonSerializable()
class _SuperIdentity extends SuperIdentity {
const _SuperIdentity(

View file

@ -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<Map<String, dynamic>> 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
? <String>[]
: 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<VeilidConfig> 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<VeilidConfig> 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

View file

@ -24,7 +24,7 @@ class TableDBArrayProtobufStateData<T extends GeneratedMessage>
final IList<T> 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;

View file

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

View file

@ -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:
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
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
@ -131,20 +139,20 @@ 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: