mirror of
https://github.com/Iconica-Development/flutter_chat.git
synced 2025-05-19 02:43:50 +02:00
Compare commits
18 commits
Author | SHA1 | Date | |
---|---|---|---|
f7f15ef750 | |||
c155531b11 | |||
|
90610caabd | ||
|
e1e23e7b35 | ||
9b365d573d | |||
3b4b456db2 | |||
|
ad615133e4 | ||
f286e7fb79 | |||
3f1caa912b | |||
7d634e54c1 | |||
|
3fbcf5d076 | ||
84cc630c6e | |||
|
3cec2ee1c6 | ||
|
b8e22425a1 | ||
|
61b588cfd5 | ||
|
02ae2aa884 | ||
|
d2f000c8a7 | ||
52562746b6 |
24 changed files with 656 additions and 162 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
@ -1,3 +1,15 @@
|
||||||
|
## 6.0.0
|
||||||
|
- Added pending message repository to temporarily store messages that are not yet received by the backend
|
||||||
|
- Added pending message icons next to time on default messages
|
||||||
|
- Added pending image uploading by base64encoding the data and putting it in the image url
|
||||||
|
- Added image pre-loading to handle error and loading states
|
||||||
|
- Added reload button in case of an image loading error
|
||||||
|
- Added messageStatus field to MessageModel to differentiate between sent and pending messages
|
||||||
|
|
||||||
|
## 5.1.2
|
||||||
|
- Added correct padding inbetween time indicators and names
|
||||||
|
- Show names if a new day occurs and an indicator is shown
|
||||||
|
|
||||||
## 5.1.1
|
## 5.1.1
|
||||||
- Expose default indicator builder from the indicator options
|
- Expose default indicator builder from the indicator options
|
||||||
|
|
||||||
|
|
|
@ -2,9 +2,11 @@
|
||||||
export "src/exceptions/chat.dart";
|
export "src/exceptions/chat.dart";
|
||||||
// Interfaces
|
// Interfaces
|
||||||
export "src/interfaces/chat_repostory_interface.dart";
|
export "src/interfaces/chat_repostory_interface.dart";
|
||||||
|
export "src/interfaces/pending_message_repository_interface.dart";
|
||||||
export "src/interfaces/user_repository_interface.dart";
|
export "src/interfaces/user_repository_interface.dart";
|
||||||
// Local implementations
|
// Local implementations
|
||||||
export "src/local/local_chat_repository.dart";
|
export "src/local/local_chat_repository.dart";
|
||||||
|
export "src/local/local_pending_message_repository.dart";
|
||||||
export "src/local/local_user_repository.dart";
|
export "src/local/local_user_repository.dart";
|
||||||
// Models
|
// Models
|
||||||
export "src/models/chat_model.dart";
|
export "src/models/chat_model.dart";
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
import "dart:convert";
|
||||||
|
import "dart:typed_data";
|
||||||
|
import "package:mime/mime.dart";
|
||||||
|
|
||||||
|
/// Error thrown when there is no
|
||||||
|
/// mimetype found
|
||||||
|
class MimetypeMissingError extends Error {
|
||||||
|
@override
|
||||||
|
String toString() => "You can only provide files that contain a mimetype";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extension that provides a converter function from
|
||||||
|
/// Uin8List to a base64Encoded data uri.
|
||||||
|
extension ToDataUri on Uint8List {
|
||||||
|
/// This function converts the Uint8List into
|
||||||
|
/// a uri with a data-scheme.
|
||||||
|
String toDataUri() {
|
||||||
|
var mimeType = lookupMimeType("", headerBytes: this);
|
||||||
|
if (mimeType == null) throw MimetypeMissingError();
|
||||||
|
|
||||||
|
var base64Data = base64Encode(this);
|
||||||
|
|
||||||
|
return "data:$mimeType;base64,$base64Data";
|
||||||
|
}
|
||||||
|
}
|
|
@ -73,11 +73,27 @@ abstract class ChatRepositoryInterface {
|
||||||
required MessageModel firstMessage,
|
required MessageModel firstMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Retrieve the next unused message id given a current chat.
|
||||||
|
///
|
||||||
|
/// The resulting string should be at least unique per [chatId]. The userId
|
||||||
|
/// is provided in case the specific user has influence on the id.
|
||||||
|
///
|
||||||
|
/// Imagine returning a UUID, the next integer in a counter or the document
|
||||||
|
/// id in firebase.
|
||||||
|
Future<String> getNextMessageId({
|
||||||
|
required String userId,
|
||||||
|
required String chatId,
|
||||||
|
});
|
||||||
|
|
||||||
/// Send a message with the given parameters.
|
/// Send a message with the given parameters.
|
||||||
|
///
|
||||||
/// [chatId] is the chat id.
|
/// [chatId] is the chat id.
|
||||||
/// [senderId] is the sender id.
|
/// [senderId] is the sender id.
|
||||||
|
/// [messageId] is the identifier for this message
|
||||||
/// [text] is the message text.
|
/// [text] is the message text.
|
||||||
/// [imageUrl] is the image url.
|
/// [imageUrl] is the image url.
|
||||||
|
/// [messageType] is a way to identify a difference in messages
|
||||||
|
/// [timestamp] is the moment of sending.
|
||||||
Future<void> sendMessage({
|
Future<void> sendMessage({
|
||||||
required String chatId,
|
required String chatId,
|
||||||
required String senderId,
|
required String senderId,
|
||||||
|
|
|
@ -0,0 +1,42 @@
|
||||||
|
import "package:chat_repository_interface/src/models/message_model.dart";
|
||||||
|
|
||||||
|
/// The pending chat messages repository interface
|
||||||
|
/// Implement this interface to create a pending chat
|
||||||
|
/// messages repository with a given data source.
|
||||||
|
abstract class PendingMessageRepositoryInterface {
|
||||||
|
/// Get the messages for the given [chatId].
|
||||||
|
/// Returns a list of [MessageModel] stream.
|
||||||
|
/// [userId] is the user id.
|
||||||
|
/// [chatId] is the chat id.
|
||||||
|
/// Returns a list of [MessageModel] stream.
|
||||||
|
Stream<List<MessageModel>> getMessages({
|
||||||
|
required String chatId,
|
||||||
|
required String userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Create a message in the pending messages and return the created message.
|
||||||
|
///
|
||||||
|
/// [chatId] is the chat id.
|
||||||
|
/// [senderId] is the sender id.
|
||||||
|
/// [messageId] is the identifier for this message
|
||||||
|
/// [text] is the message text.
|
||||||
|
/// [imageUrl] is the image url.
|
||||||
|
/// [messageType] is a way to identify a difference in messages
|
||||||
|
/// [timestamp] is the moment of sending.
|
||||||
|
Future<MessageModel> createMessage({
|
||||||
|
required String chatId,
|
||||||
|
required String senderId,
|
||||||
|
required String messageId,
|
||||||
|
String? text,
|
||||||
|
String? imageUrl,
|
||||||
|
String? messageType,
|
||||||
|
DateTime? timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Mark a message as being succesfully sent to the server,
|
||||||
|
/// so that it can be removed from this data source.
|
||||||
|
Future<void> markMessageSent({
|
||||||
|
required String chatId,
|
||||||
|
required String messageId,
|
||||||
|
});
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ import "dart:math" as math;
|
||||||
import "dart:typed_data";
|
import "dart:typed_data";
|
||||||
|
|
||||||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||||
|
import "package:chat_repository_interface/src/extension/uint8list_data_uri.dart";
|
||||||
import "package:chat_repository_interface/src/local/local_memory_db.dart";
|
import "package:chat_repository_interface/src/local/local_memory_db.dart";
|
||||||
import "package:collection/collection.dart";
|
import "package:collection/collection.dart";
|
||||||
import "package:rxdart/rxdart.dart";
|
import "package:rxdart/rxdart.dart";
|
||||||
|
@ -207,6 +208,13 @@ class LocalChatRepository implements ChatRepositoryInterface {
|
||||||
return Stream.value(message);
|
return Stream.value(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> getNextMessageId({
|
||||||
|
required String userId,
|
||||||
|
required String chatId,
|
||||||
|
}) async =>
|
||||||
|
"$chatId-$userId-${DateTime.now()}";
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> sendMessage({
|
Future<void> sendMessage({
|
||||||
required String chatId,
|
required String chatId,
|
||||||
|
@ -225,6 +233,7 @@ class LocalChatRepository implements ChatRepositoryInterface {
|
||||||
messageType: messageType,
|
messageType: messageType,
|
||||||
senderId: senderId,
|
senderId: senderId,
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
|
status: MessageStatus.sent,
|
||||||
);
|
);
|
||||||
|
|
||||||
var chat = chats.firstWhereOrNull((e) => e.id == chatId);
|
var chat = chats.firstWhereOrNull((e) => e.id == chatId);
|
||||||
|
@ -271,7 +280,7 @@ class LocalChatRepository implements ChatRepositoryInterface {
|
||||||
required String chatId,
|
required String chatId,
|
||||||
required String senderId,
|
required String senderId,
|
||||||
}) =>
|
}) =>
|
||||||
Future.value("https://picsum.photos/200/300");
|
Future.value(image.toDataUri());
|
||||||
|
|
||||||
/// All the chats of the local memory database
|
/// All the chats of the local memory database
|
||||||
List<ChatModel> get getLocalChats => chats;
|
List<ChatModel> get getLocalChats => chats;
|
||||||
|
|
|
@ -11,6 +11,9 @@ final List<ChatModel> chats = [];
|
||||||
/// All the messages of the local memory database mapped by chat id
|
/// All the messages of the local memory database mapped by chat id
|
||||||
final Map<String, List<MessageModel>> chatMessages = {};
|
final Map<String, List<MessageModel>> chatMessages = {};
|
||||||
|
|
||||||
|
/// All the pending messages of the local memory database mapped by chat id
|
||||||
|
final Map<String, List<MessageModel>> pendingChatMessages = {};
|
||||||
|
|
||||||
/// All the users of the local memory database
|
/// All the users of the local memory database
|
||||||
final List<UserModel> users = [
|
final List<UserModel> users = [
|
||||||
const UserModel(
|
const UserModel(
|
||||||
|
|
|
@ -0,0 +1,90 @@
|
||||||
|
import "dart:async";
|
||||||
|
|
||||||
|
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||||
|
import "package:chat_repository_interface/src/local/local_memory_db.dart";
|
||||||
|
import "package:collection/collection.dart";
|
||||||
|
import "package:rxdart/rxdart.dart";
|
||||||
|
|
||||||
|
/// The local pending message repository
|
||||||
|
class LocalPendingMessageRepository
|
||||||
|
implements PendingMessageRepositoryInterface {
|
||||||
|
/// The local pending message repository constructor
|
||||||
|
LocalPendingMessageRepository();
|
||||||
|
|
||||||
|
final StreamController<List<MessageModel>> _messageController =
|
||||||
|
BehaviorSubject<List<MessageModel>>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<MessageModel>> getMessages({
|
||||||
|
required String chatId,
|
||||||
|
required String userId,
|
||||||
|
}) {
|
||||||
|
var foundChat =
|
||||||
|
chats.firstWhereOrNull((chatModel) => chatModel.id == chatId);
|
||||||
|
|
||||||
|
if (foundChat == null) {
|
||||||
|
_messageController.add([]);
|
||||||
|
} else {
|
||||||
|
var allMessages = List<MessageModel>.from(
|
||||||
|
pendingChatMessages[chatId] ?? [],
|
||||||
|
);
|
||||||
|
allMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||||
|
|
||||||
|
_messageController.add(allMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _messageController.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _chatExists(String chatId) async {
|
||||||
|
var chat = chats.firstWhereOrNull((e) => e.id == chatId);
|
||||||
|
if (chat == null) throw Exception("Chat not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MessageModel> createMessage({
|
||||||
|
required String chatId,
|
||||||
|
required String senderId,
|
||||||
|
required String messageId,
|
||||||
|
String? text,
|
||||||
|
String? imageUrl,
|
||||||
|
String? messageType,
|
||||||
|
DateTime? timestamp,
|
||||||
|
}) async {
|
||||||
|
var message = MessageModel(
|
||||||
|
chatId: chatId,
|
||||||
|
id: messageId,
|
||||||
|
timestamp: timestamp ?? DateTime.now(),
|
||||||
|
text: text,
|
||||||
|
messageType: messageType,
|
||||||
|
senderId: senderId,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
status: MessageStatus.sending,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _chatExists(chatId);
|
||||||
|
|
||||||
|
var messages = List<MessageModel>.from(pendingChatMessages[chatId] ?? []);
|
||||||
|
messages.add(message);
|
||||||
|
|
||||||
|
pendingChatMessages[chatId] = messages;
|
||||||
|
|
||||||
|
_messageController.add(pendingChatMessages[chatId] ?? []);
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markMessageSent({
|
||||||
|
required String chatId,
|
||||||
|
required String messageId,
|
||||||
|
}) async {
|
||||||
|
await _chatExists(chatId);
|
||||||
|
var messages = List<MessageModel>.from(pendingChatMessages[chatId] ?? []);
|
||||||
|
|
||||||
|
MessageModel markSent(MessageModel message) =>
|
||||||
|
(message.id == messageId) ? message.markSent() : message;
|
||||||
|
|
||||||
|
pendingChatMessages[chatId] = messages.map(markSent).toList();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,24 @@
|
||||||
|
/// Message status enumeration
|
||||||
|
enum MessageStatus {
|
||||||
|
/// Status when a message has not yet been received by the server.
|
||||||
|
sending,
|
||||||
|
|
||||||
|
/// Status used when a message has been received by the server.
|
||||||
|
sent;
|
||||||
|
|
||||||
|
/// Attempt to parse [MessageStatus] from String
|
||||||
|
static MessageStatus? tryParse(String name) =>
|
||||||
|
MessageStatus.values.where((status) => status.name == name).firstOrNull;
|
||||||
|
|
||||||
|
/// Parse [MessageStatus] from String
|
||||||
|
/// or throw a [FormatException]
|
||||||
|
static MessageStatus parse(String name) =>
|
||||||
|
tryParse(name) ??
|
||||||
|
(throw const FormatException(
|
||||||
|
"MessageStatus with that name does not exist",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/// Message model
|
/// Message model
|
||||||
/// Represents a message in a chat
|
/// Represents a message in a chat
|
||||||
/// [id] is the message id.
|
/// [id] is the message id.
|
||||||
|
@ -15,6 +36,7 @@ class MessageModel {
|
||||||
required this.imageUrl,
|
required this.imageUrl,
|
||||||
required this.timestamp,
|
required this.timestamp,
|
||||||
required this.senderId,
|
required this.senderId,
|
||||||
|
this.status = MessageStatus.sent,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Creates a message model instance given a map instance
|
/// Creates a message model instance given a map instance
|
||||||
|
@ -27,6 +49,7 @@ class MessageModel {
|
||||||
imageUrl: map["imageUrl"],
|
imageUrl: map["imageUrl"],
|
||||||
timestamp: DateTime.fromMillisecondsSinceEpoch(map["timestamp"]),
|
timestamp: DateTime.fromMillisecondsSinceEpoch(map["timestamp"]),
|
||||||
senderId: map["senderId"],
|
senderId: map["senderId"],
|
||||||
|
status: MessageStatus.tryParse(map["status"]) ?? MessageStatus.sent,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// The chat id
|
/// The chat id
|
||||||
|
@ -50,6 +73,9 @@ class MessageModel {
|
||||||
/// The sender id
|
/// The sender id
|
||||||
final String senderId;
|
final String senderId;
|
||||||
|
|
||||||
|
/// The message status
|
||||||
|
final MessageStatus status;
|
||||||
|
|
||||||
/// The message model copy with method
|
/// The message model copy with method
|
||||||
MessageModel copyWith({
|
MessageModel copyWith({
|
||||||
String? chatId,
|
String? chatId,
|
||||||
|
@ -59,6 +85,7 @@ class MessageModel {
|
||||||
String? imageUrl,
|
String? imageUrl,
|
||||||
DateTime? timestamp,
|
DateTime? timestamp,
|
||||||
String? senderId,
|
String? senderId,
|
||||||
|
MessageStatus? status,
|
||||||
}) =>
|
}) =>
|
||||||
MessageModel(
|
MessageModel(
|
||||||
chatId: chatId ?? this.chatId,
|
chatId: chatId ?? this.chatId,
|
||||||
|
@ -68,6 +95,7 @@ class MessageModel {
|
||||||
imageUrl: imageUrl ?? this.imageUrl,
|
imageUrl: imageUrl ?? this.imageUrl,
|
||||||
timestamp: timestamp ?? this.timestamp,
|
timestamp: timestamp ?? this.timestamp,
|
||||||
senderId: senderId ?? this.senderId,
|
senderId: senderId ?? this.senderId,
|
||||||
|
status: status ?? this.status,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Creates a map representation of this object
|
/// Creates a map representation of this object
|
||||||
|
@ -78,7 +106,11 @@ class MessageModel {
|
||||||
"imageUrl": imageUrl,
|
"imageUrl": imageUrl,
|
||||||
"timestamp": timestamp.millisecondsSinceEpoch,
|
"timestamp": timestamp.millisecondsSinceEpoch,
|
||||||
"senderId": senderId,
|
"senderId": senderId,
|
||||||
|
"status": status.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// marks the message model as sent
|
||||||
|
MessageModel markSent() => copyWith(status: MessageStatus.sent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extension on [MessageModel] to check the message type
|
/// Extension on [MessageModel] to check the message type
|
||||||
|
|
|
@ -1,14 +1,18 @@
|
||||||
import "dart:async";
|
import "dart:async";
|
||||||
import "dart:typed_data";
|
import "dart:typed_data";
|
||||||
|
|
||||||
|
import "package:chat_repository_interface/src/extension/uint8list_data_uri.dart";
|
||||||
import "package:chat_repository_interface/src/interfaces/chat_repostory_interface.dart";
|
import "package:chat_repository_interface/src/interfaces/chat_repostory_interface.dart";
|
||||||
|
import "package:chat_repository_interface/src/interfaces/pending_message_repository_interface.dart";
|
||||||
import "package:chat_repository_interface/src/interfaces/user_repository_interface.dart";
|
import "package:chat_repository_interface/src/interfaces/user_repository_interface.dart";
|
||||||
import "package:chat_repository_interface/src/local/local_chat_repository.dart";
|
import "package:chat_repository_interface/src/local/local_chat_repository.dart";
|
||||||
|
import "package:chat_repository_interface/src/local/local_pending_message_repository.dart";
|
||||||
import "package:chat_repository_interface/src/local/local_user_repository.dart";
|
import "package:chat_repository_interface/src/local/local_user_repository.dart";
|
||||||
import "package:chat_repository_interface/src/models/chat_model.dart";
|
import "package:chat_repository_interface/src/models/chat_model.dart";
|
||||||
import "package:chat_repository_interface/src/models/message_model.dart";
|
import "package:chat_repository_interface/src/models/message_model.dart";
|
||||||
import "package:chat_repository_interface/src/models/user_model.dart";
|
import "package:chat_repository_interface/src/models/user_model.dart";
|
||||||
import "package:collection/collection.dart";
|
import "package:collection/collection.dart";
|
||||||
|
import "package:rxdart/rxdart.dart";
|
||||||
|
|
||||||
/// The chat service
|
/// The chat service
|
||||||
/// Use this service to interact with the chat repository.
|
/// Use this service to interact with the chat repository.
|
||||||
|
@ -18,9 +22,12 @@ class ChatService {
|
||||||
ChatService({
|
ChatService({
|
||||||
required this.userId,
|
required this.userId,
|
||||||
ChatRepositoryInterface? chatRepository,
|
ChatRepositoryInterface? chatRepository,
|
||||||
|
PendingMessageRepositoryInterface? pendingMessageRepository,
|
||||||
UserRepositoryInterface? userRepository,
|
UserRepositoryInterface? userRepository,
|
||||||
}) : chatRepository = chatRepository ?? LocalChatRepository(),
|
}) : chatRepository = chatRepository ?? LocalChatRepository(),
|
||||||
userRepository = userRepository ?? LocalUserRepository();
|
userRepository = userRepository ?? LocalUserRepository(),
|
||||||
|
pendingMessageRepository =
|
||||||
|
pendingMessageRepository ?? LocalPendingMessageRepository();
|
||||||
|
|
||||||
/// The user ID of the person currently looking at the chat
|
/// The user ID of the person currently looking at the chat
|
||||||
final String userId;
|
final String userId;
|
||||||
|
@ -28,6 +35,9 @@ class ChatService {
|
||||||
/// The chat repository
|
/// The chat repository
|
||||||
final ChatRepositoryInterface chatRepository;
|
final ChatRepositoryInterface chatRepository;
|
||||||
|
|
||||||
|
/// The pending messages repository
|
||||||
|
final PendingMessageRepositoryInterface pendingMessageRepository;
|
||||||
|
|
||||||
/// The user repository
|
/// The user repository
|
||||||
final UserRepositoryInterface userRepository;
|
final UserRepositoryInterface userRepository;
|
||||||
|
|
||||||
|
@ -135,11 +145,34 @@ class ChatService {
|
||||||
/// Returns a list of [MessageModel] stream.
|
/// Returns a list of [MessageModel] stream.
|
||||||
Stream<List<MessageModel>?> getMessages({
|
Stream<List<MessageModel>?> getMessages({
|
||||||
required String chatId,
|
required String chatId,
|
||||||
}) =>
|
}) {
|
||||||
chatRepository.getMessages(
|
List<MessageModel> mergePendingMessages(
|
||||||
userId: userId,
|
List<MessageModel> messages,
|
||||||
chatId: chatId,
|
List<MessageModel> pendingMessages,
|
||||||
);
|
) =>
|
||||||
|
{
|
||||||
|
...Map.fromEntries(
|
||||||
|
pendingMessages.map((message) => MapEntry(message.id, message)),
|
||||||
|
),
|
||||||
|
...Map.fromEntries(
|
||||||
|
messages.map((message) => MapEntry(message.id, message)),
|
||||||
|
),
|
||||||
|
}.values.toList().sorted(
|
||||||
|
(a, b) => a.timestamp.compareTo(b.timestamp),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Rx.combineLatest2(
|
||||||
|
chatRepository.getMessages(userId: userId, chatId: chatId),
|
||||||
|
pendingMessageRepository.getMessages(userId: userId, chatId: chatId),
|
||||||
|
(chatMessages, pendingChatMessages) {
|
||||||
|
// TODO(Quirille): This is because chatRepository.getMessages
|
||||||
|
// might return null, when really it should've just thrown
|
||||||
|
// an exception instead.
|
||||||
|
if (chatMessages == null) return null;
|
||||||
|
return mergePendingMessages(chatMessages, pendingChatMessages);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Signals that new messages should be loaded after the given message.
|
/// Signals that new messages should be loaded after the given message.
|
||||||
/// The stream should emit the new messages.
|
/// The stream should emit the new messages.
|
||||||
|
@ -169,19 +202,73 @@ class ChatService {
|
||||||
Future<void> sendMessage({
|
Future<void> sendMessage({
|
||||||
required String chatId,
|
required String chatId,
|
||||||
required String senderId,
|
required String senderId,
|
||||||
required String messageId,
|
String? presetMessageId,
|
||||||
String? text,
|
String? text,
|
||||||
String? messageType,
|
String? messageType,
|
||||||
String? imageUrl,
|
String? imageUrl,
|
||||||
}) =>
|
Uint8List? imageData,
|
||||||
chatRepository.sendMessage(
|
}) async {
|
||||||
chatId: chatId,
|
var messageId = presetMessageId ??
|
||||||
messageId: messageId,
|
await chatRepository.getNextMessageId(userId: userId, chatId: chatId);
|
||||||
text: text,
|
|
||||||
messageType: messageType,
|
await pendingMessageRepository.createMessage(
|
||||||
senderId: senderId,
|
chatId: chatId,
|
||||||
imageUrl: imageUrl,
|
senderId: senderId,
|
||||||
);
|
messageId: messageId,
|
||||||
|
text: text,
|
||||||
|
messageType: messageType,
|
||||||
|
imageUrl: imageData?.toDataUri() ?? imageUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
unawaited(
|
||||||
|
chatRepository
|
||||||
|
.sendMessage(
|
||||||
|
chatId: chatId,
|
||||||
|
messageId: messageId,
|
||||||
|
text: text,
|
||||||
|
messageType: messageType,
|
||||||
|
senderId: senderId,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
(_) => pendingMessageRepository.markMessageSent(
|
||||||
|
chatId: chatId,
|
||||||
|
messageId: messageId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.onError(
|
||||||
|
(e, s) {
|
||||||
|
// TODO(Quirille): handle exception when message sending has failed.
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Method for sending an image and a message at the same time.
|
||||||
|
Future<void> sendImageMessage({
|
||||||
|
required String chatId,
|
||||||
|
required String userId,
|
||||||
|
required Uint8List data,
|
||||||
|
}) async {
|
||||||
|
var messageId = await chatRepository.getNextMessageId(
|
||||||
|
userId: userId,
|
||||||
|
chatId: chatId,
|
||||||
|
);
|
||||||
|
|
||||||
|
var path = await uploadImage(
|
||||||
|
path: "chats/$messageId",
|
||||||
|
image: data,
|
||||||
|
chatId: chatId,
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendMessage(
|
||||||
|
presetMessageId: messageId,
|
||||||
|
chatId: chatId,
|
||||||
|
senderId: userId,
|
||||||
|
imageUrl: path,
|
||||||
|
imageData: data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete the chat with the given parameters.
|
/// Delete the chat with the given parameters.
|
||||||
/// [chatId] is the chat id.
|
/// [chatId] is the chat id.
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
name: chat_repository_interface
|
name: chat_repository_interface
|
||||||
description: "The interface for a chat repository"
|
description: "The interface for a chat repository"
|
||||||
version: 5.1.1
|
version: 6.0.0
|
||||||
homepage: "https://github.com/Iconica-Development"
|
homepage: "https://github.com/Iconica-Development"
|
||||||
|
|
||||||
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/
|
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/
|
||||||
|
@ -9,6 +9,7 @@ environment:
|
||||||
sdk: ">=3.4.3 <4.0.0"
|
sdk: ">=3.4.3 <4.0.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
mime: any
|
||||||
rxdart: any
|
rxdart: any
|
||||||
collection: any
|
collection: any
|
||||||
|
|
||||||
|
|
|
@ -140,6 +140,13 @@ class FirebaseChatRepository implements ChatRepositoryInterface {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> getNextMessageId({
|
||||||
|
required String userId,
|
||||||
|
required String chatId,
|
||||||
|
}) async =>
|
||||||
|
"$chatId-$userId-${DateTime.now()}";
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> sendMessage({
|
Future<void> sendMessage({
|
||||||
required String chatId,
|
required String chatId,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
name: firebase_chat_repository
|
name: firebase_chat_repository
|
||||||
description: "Firebase repository implementation for the chat domain repository interface"
|
description: "Firebase repository implementation for the chat domain repository interface"
|
||||||
version: 5.1.1
|
version: 6.0.0
|
||||||
homepage: "https://github.com/Iconica-Development"
|
homepage: "https://github.com/Iconica-Development"
|
||||||
|
|
||||||
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/
|
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/
|
||||||
|
@ -15,7 +15,7 @@ dependencies:
|
||||||
|
|
||||||
chat_repository_interface:
|
chat_repository_interface:
|
||||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||||
version: ^5.1.0
|
version: ^6.0.0
|
||||||
|
|
||||||
firebase_storage: any
|
firebase_storage: any
|
||||||
cloud_firestore: any
|
cloud_firestore: any
|
||||||
|
|
|
@ -19,18 +19,19 @@ class MyApp extends StatelessWidget {
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
),
|
),
|
||||||
home: const MyHomePage(),
|
home: const MyHomePage(),
|
||||||
// FutureBuilder(
|
// home: FutureBuilder(
|
||||||
// future: Firebase.initializeApp(
|
// future: Firebase.initializeApp(
|
||||||
// options: DefaultFirebaseOptions.currentPlatform,
|
// options: DefaultFirebaseOptions.currentPlatform,
|
||||||
|
// ),
|
||||||
|
// builder: (context, snapshot) {
|
||||||
|
// if (snapshot.connectionState != ConnectionState.done) {
|
||||||
|
// return const Center(
|
||||||
|
// child: CircularProgressIndicator(),
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// return const MyHomePage();
|
||||||
|
// },
|
||||||
// ),
|
// ),
|
||||||
// builder: (context, snapshot) {
|
|
||||||
// if (snapshot.connectionState != ConnectionState.done) {
|
|
||||||
// return const Center(
|
|
||||||
// child: CircularProgressIndicator(),
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// return const MyHomePage();
|
|
||||||
// }),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -45,8 +46,8 @@ class MyHomePage extends StatefulWidget {
|
||||||
class _MyHomePageState extends State<MyHomePage> {
|
class _MyHomePageState extends State<MyHomePage> {
|
||||||
// @override
|
// @override
|
||||||
// void initState() {
|
// void initState() {
|
||||||
// FirebaseAuth.instance.signInAnonymously();
|
// FirebaseAuth.instance.signInAnonymously();
|
||||||
// super.initState();
|
// super.initState();
|
||||||
// }
|
// }
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
@ -29,12 +29,19 @@ class ChatOptions {
|
||||||
this.timeIndicatorOptions = const ChatTimeIndicatorOptions(),
|
this.timeIndicatorOptions = const ChatTimeIndicatorOptions(),
|
||||||
ChatRepositoryInterface? chatRepository,
|
ChatRepositoryInterface? chatRepository,
|
||||||
UserRepositoryInterface? userRepository,
|
UserRepositoryInterface? userRepository,
|
||||||
|
PendingMessageRepositoryInterface? pendingMessagesRepository,
|
||||||
}) : chatRepository = chatRepository ?? LocalChatRepository(),
|
}) : chatRepository = chatRepository ?? LocalChatRepository(),
|
||||||
userRepository = userRepository ?? LocalUserRepository();
|
userRepository = userRepository ?? LocalUserRepository(),
|
||||||
|
pendingMessagesRepository =
|
||||||
|
pendingMessagesRepository ?? LocalPendingMessageRepository();
|
||||||
|
|
||||||
/// The implementation for communication with persistance layer for chats
|
/// The implementation for communication with persistance layer for chats
|
||||||
final ChatRepositoryInterface chatRepository;
|
final ChatRepositoryInterface chatRepository;
|
||||||
|
|
||||||
|
/// The implementation for communication with persistance layer
|
||||||
|
/// for pending messages
|
||||||
|
final PendingMessageRepositoryInterface pendingMessagesRepository;
|
||||||
|
|
||||||
/// The implementation for communication with persistance layer for users
|
/// The implementation for communication with persistance layer for users
|
||||||
final UserRepositoryInterface userRepository;
|
final UserRepositoryInterface userRepository;
|
||||||
|
|
||||||
|
@ -147,6 +154,7 @@ class MessageTheme {
|
||||||
this.borderColor,
|
this.borderColor,
|
||||||
this.textColor,
|
this.textColor,
|
||||||
this.timeTextColor,
|
this.timeTextColor,
|
||||||
|
this.imageBackgroundColor,
|
||||||
this.borderRadius,
|
this.borderRadius,
|
||||||
this.messageAlignment,
|
this.messageAlignment,
|
||||||
this.messageSidePadding,
|
this.messageSidePadding,
|
||||||
|
@ -163,6 +171,7 @@ class MessageTheme {
|
||||||
borderColor: theme.colorScheme.primary,
|
borderColor: theme.colorScheme.primary,
|
||||||
textColor: theme.colorScheme.onPrimary,
|
textColor: theme.colorScheme.onPrimary,
|
||||||
timeTextColor: theme.colorScheme.onPrimary,
|
timeTextColor: theme.colorScheme.onPrimary,
|
||||||
|
imageBackgroundColor: theme.colorScheme.secondaryContainer,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
textAlignment: TextAlign.start,
|
textAlignment: TextAlign.start,
|
||||||
messageSidePadding: 144.0,
|
messageSidePadding: 144.0,
|
||||||
|
@ -201,6 +210,12 @@ class MessageTheme {
|
||||||
/// Defaults to [ThemeData.colorScheme.primaryColor]
|
/// Defaults to [ThemeData.colorScheme.primaryColor]
|
||||||
final Color? borderColor;
|
final Color? borderColor;
|
||||||
|
|
||||||
|
/// The color of the background when an image is loading, the image is
|
||||||
|
/// transparent or there is an error.
|
||||||
|
///
|
||||||
|
/// Defaults to [ThemeData.colorScheme.secondaryContainer]
|
||||||
|
final Color? imageBackgroundColor;
|
||||||
|
|
||||||
/// The border radius of the message container
|
/// The border radius of the message container
|
||||||
/// Defaults to [BorderRadius.circular(12)]
|
/// Defaults to [BorderRadius.circular(12)]
|
||||||
final BorderRadius? borderRadius;
|
final BorderRadius? borderRadius;
|
||||||
|
@ -231,6 +246,7 @@ class MessageTheme {
|
||||||
Color? borderColor,
|
Color? borderColor,
|
||||||
Color? textColor,
|
Color? textColor,
|
||||||
Color? timeTextColor,
|
Color? timeTextColor,
|
||||||
|
Color? imageBackgroundColor,
|
||||||
BorderRadius? borderRadius,
|
BorderRadius? borderRadius,
|
||||||
double? messageSidePadding,
|
double? messageSidePadding,
|
||||||
TextAlign? messageAlignment,
|
TextAlign? messageAlignment,
|
||||||
|
@ -245,6 +261,7 @@ class MessageTheme {
|
||||||
borderColor: borderColor ?? this.borderColor,
|
borderColor: borderColor ?? this.borderColor,
|
||||||
textColor: textColor ?? this.textColor,
|
textColor: textColor ?? this.textColor,
|
||||||
timeTextColor: timeTextColor ?? this.timeTextColor,
|
timeTextColor: timeTextColor ?? this.timeTextColor,
|
||||||
|
imageBackgroundColor: imageBackgroundColor ?? this.imageBackgroundColor,
|
||||||
borderRadius: borderRadius ?? this.borderRadius,
|
borderRadius: borderRadius ?? this.borderRadius,
|
||||||
messageSidePadding: messageSidePadding ?? this.messageSidePadding,
|
messageSidePadding: messageSidePadding ?? this.messageSidePadding,
|
||||||
messageAlignment: messageAlignment ?? this.messageAlignment,
|
messageAlignment: messageAlignment ?? this.messageAlignment,
|
||||||
|
@ -262,6 +279,8 @@ class MessageTheme {
|
||||||
borderColor: borderColor ?? other.borderColor,
|
borderColor: borderColor ?? other.borderColor,
|
||||||
textColor: textColor ?? other.textColor,
|
textColor: textColor ?? other.textColor,
|
||||||
timeTextColor: timeTextColor ?? other.timeTextColor,
|
timeTextColor: timeTextColor ?? other.timeTextColor,
|
||||||
|
imageBackgroundColor:
|
||||||
|
imageBackgroundColor ?? other.imageBackgroundColor,
|
||||||
borderRadius: borderRadius ?? other.borderRadius,
|
borderRadius: borderRadius ?? other.borderRadius,
|
||||||
messageSidePadding: messageSidePadding ?? other.messageSidePadding,
|
messageSidePadding: messageSidePadding ?? other.messageSidePadding,
|
||||||
messageAlignment: messageAlignment ?? other.messageAlignment,
|
messageAlignment: messageAlignment ?? other.messageAlignment,
|
||||||
|
@ -284,7 +303,10 @@ ImageProvider _defaultImageProviderResolver(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Uri image,
|
Uri image,
|
||||||
) =>
|
) =>
|
||||||
CachedNetworkImageProvider(image.toString());
|
switch (image.scheme) {
|
||||||
|
"data" => MemoryImage(image.data!.contentAsBytes()),
|
||||||
|
_ => CachedNetworkImageProvider(image.toString()),
|
||||||
|
};
|
||||||
|
|
||||||
/// All configurable paddings and whitespaces within the userstory
|
/// All configurable paddings and whitespaces within the userstory
|
||||||
class ChatSpacing {
|
class ChatSpacing {
|
||||||
|
|
|
@ -168,7 +168,7 @@ class ChatTranslations {
|
||||||
-1 => "Yesterday",
|
-1 => "Yesterday",
|
||||||
1 => "Tomorrow",
|
1 => "Tomorrow",
|
||||||
int value when value < 5 && value > 1 => "In $value days",
|
int value when value < 5 && value > 1 => "In $value days",
|
||||||
int value when value < -1 && value > -5 => "$value days ago",
|
int value when value < -1 && value > -5 => "${value.abs()} days ago",
|
||||||
_ => DateFormat("dd-MM-YYYY").format(time),
|
_ => DateFormat("dd-MM-YYYY").format(time),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -83,6 +83,7 @@ class _FlutterChatEntryWidgetState extends State<FlutterChatEntryWidget> {
|
||||||
userId: widget.userId,
|
userId: widget.userId,
|
||||||
chatRepository: widget.options?.chatRepository,
|
chatRepository: widget.options?.chatRepository,
|
||||||
userRepository: widget.options?.userRepository,
|
userRepository: widget.options?.userRepository,
|
||||||
|
pendingMessageRepository: widget.options?.pendingMessagesRepository,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -94,6 +94,7 @@ abstract class _BaseChatNavigatorUserstory extends HookWidget {
|
||||||
userId: userId,
|
userId: userId,
|
||||||
chatRepository: options.chatRepository,
|
chatRepository: options.chatRepository,
|
||||||
userRepository: options.userRepository,
|
userRepository: options.userRepository,
|
||||||
|
pendingMessageRepository: options.pendingMessagesRepository,
|
||||||
),
|
),
|
||||||
[userId, options],
|
[userId, options],
|
||||||
);
|
);
|
||||||
|
|
|
@ -50,27 +50,16 @@ MaterialPageRoute chatDetailRoute({
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
onExit: onExit,
|
onExit: onExit,
|
||||||
onReadChat: (chat) async => chatService.markAsRead(chatId: chat.id),
|
onReadChat: (chat) async => chatService.markAsRead(chatId: chat.id),
|
||||||
onUploadImage: (data) async {
|
onUploadImage: (data) async => chatService.sendImageMessage(
|
||||||
var path = await chatService.uploadImage(
|
chatId: chatId,
|
||||||
path: "chats/$chatId-$userId-${DateTime.now()}",
|
userId: userId,
|
||||||
image: data,
|
data: data,
|
||||||
chatId: chatId,
|
),
|
||||||
);
|
onMessageSubmit: (text) async => chatService.sendMessage(
|
||||||
await chatService.sendMessage(
|
chatId: chatId,
|
||||||
messageId: "$chatId-$userId-${DateTime.now()}",
|
senderId: userId,
|
||||||
chatId: chatId,
|
text: text,
|
||||||
senderId: userId,
|
),
|
||||||
imageUrl: path,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onMessageSubmit: (text) async {
|
|
||||||
await chatService.sendMessage(
|
|
||||||
messageId: "$chatId-$userId-${DateTime.now()}",
|
|
||||||
chatId: chatId,
|
|
||||||
senderId: userId,
|
|
||||||
text: text,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onPressChatTitle: (chat) async {
|
onPressChatTitle: (chat) async {
|
||||||
if (chat.isGroupChat) {
|
if (chat.isGroupChat) {
|
||||||
await _routeToScreen(
|
await _routeToScreen(
|
||||||
|
|
|
@ -497,19 +497,27 @@ class _ChatBody extends HookWidget {
|
||||||
bottomSpinner,
|
bottomSpinner,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
var messageList = ListView.builder(
|
||||||
|
reverse: false,
|
||||||
|
controller: scrollController,
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.only(top: 24),
|
||||||
|
itemCount: listViewChildren.length,
|
||||||
|
itemBuilder: (context, index) => listViewChildren[index],
|
||||||
|
);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
if (chatIsLoading && options.enableLoadingIndicator) ...[
|
if (chatIsLoading && options.enableLoadingIndicator) ...[
|
||||||
Expanded(child: options.builders.loadingWidgetBuilder.call(context)),
|
Expanded(
|
||||||
|
child: _CloseKeyboardOnTap(
|
||||||
|
child: options.builders.loadingWidgetBuilder.call(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: _CloseKeyboardOnTap(
|
||||||
reverse: false,
|
child: messageList,
|
||||||
controller: scrollController,
|
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
|
||||||
padding: const EdgeInsets.only(top: 24),
|
|
||||||
itemCount: listViewChildren.length,
|
|
||||||
itemBuilder: (context, index) => listViewChildren[index],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -519,6 +527,26 @@ class _ChatBody extends HookWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CloseKeyboardOnTap extends StatelessWidget {
|
||||||
|
const _CloseKeyboardOnTap({
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onTapUp: (_) {
|
||||||
|
var mediaQuery = MediaQuery.of(context);
|
||||||
|
if (mediaQuery.viewInsets.isNonNegative) {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Default widget used when displaying an error for chats.
|
/// Default widget used when displaying an error for chats.
|
||||||
class ErrorLoadingMessages extends StatelessWidget {
|
class ErrorLoadingMessages extends StatelessWidget {
|
||||||
/// Create default error displaying widget for error in loading messages
|
/// Create default error displaying widget for error in loading messages
|
||||||
|
|
|
@ -63,34 +63,37 @@ class ChatBottomInputSection extends HookWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Image and send buttons
|
/// Image and send buttons
|
||||||
var messageSendButtons = SizedBox(
|
var messageSendButtons = Padding(
|
||||||
height: 45,
|
padding: const EdgeInsets.only(right: 6.0),
|
||||||
child: Row(
|
child: SizedBox(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
height: 48,
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Row(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
CustomSemantics(
|
mainAxisSize: MainAxisSize.min,
|
||||||
identifier: options.semantics.chatSelectImageIconButton,
|
children: [
|
||||||
child: IconButton(
|
CustomSemantics(
|
||||||
alignment: Alignment.bottomRight,
|
identifier: options.semantics.chatSelectImageIconButton,
|
||||||
onPressed: isLoading ? null : onPressSelectImage,
|
child: IconButton(
|
||||||
icon: Icon(
|
alignment: Alignment.bottomRight,
|
||||||
Icons.image_outlined,
|
onPressed: isLoading ? null : onPressSelectImage,
|
||||||
color: options.iconEnabledColor,
|
icon: Icon(
|
||||||
|
Icons.image_outlined,
|
||||||
|
color: options.iconEnabledColor,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
CustomSemantics(
|
||||||
CustomSemantics(
|
identifier: options.semantics.chatSendMessageIconButton,
|
||||||
identifier: options.semantics.chatSendMessageIconButton,
|
child: IconButton(
|
||||||
child: IconButton(
|
alignment: Alignment.bottomRight,
|
||||||
alignment: Alignment.bottomRight,
|
disabledColor: options.iconDisabledColor,
|
||||||
disabledColor: options.iconDisabledColor,
|
color: options.iconEnabledColor,
|
||||||
color: options.iconEnabledColor,
|
onPressed: isLoading ? null : onClickSendMessage,
|
||||||
onPressed: isLoading ? null : onClickSendMessage,
|
icon: const Icon(Icons.send_rounded),
|
||||||
icon: const Icon(Icons.send_rounded),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -127,10 +130,12 @@ class ChatBottomInputSection extends HookWidget {
|
||||||
),
|
),
|
||||||
// this ensures that that there is space at the end of the
|
// this ensures that that there is space at the end of the
|
||||||
// textfield
|
// textfield
|
||||||
suffixIcon: AbsorbPointer(
|
suffixIcon: ExcludeFocus(
|
||||||
child: Opacity(
|
child: AbsorbPointer(
|
||||||
opacity: 0.0,
|
child: Opacity(
|
||||||
child: messageSendButtons,
|
opacity: 0.0,
|
||||||
|
child: messageSendButtons,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
hintText: options.translations.messagePlaceholder,
|
hintText: options.translations.messagePlaceholder,
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import "dart:async";
|
||||||
|
|
||||||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:flutter_accessibility/flutter_accessibility.dart";
|
import "package:flutter_accessibility/flutter_accessibility.dart";
|
||||||
|
@ -97,10 +99,17 @@ class DefaultChatMessageBuilder extends StatelessWidget {
|
||||||
var isSameSender = previousMessage != null &&
|
var isSameSender = previousMessage != null &&
|
||||||
previousMessage?.senderId == message.senderId;
|
previousMessage?.senderId == message.senderId;
|
||||||
|
|
||||||
|
var hasPreviousIndicator = options.timeIndicatorOptions.sectionCheck(
|
||||||
|
context,
|
||||||
|
previousMessage,
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
|
||||||
var isMessageFromSelf = message.senderId == userId;
|
var isMessageFromSelf = message.senderId == userId;
|
||||||
|
|
||||||
var chatMessage = _ChatMessageBubble(
|
var chatMessage = _ChatMessageBubble(
|
||||||
isSameSender: isSameSender,
|
isSameSender: isSameSender,
|
||||||
|
hasPreviousIndicator: hasPreviousIndicator,
|
||||||
isMessageFromSelf: isMessageFromSelf,
|
isMessageFromSelf: isMessageFromSelf,
|
||||||
previousMessage: previousMessage,
|
previousMessage: previousMessage,
|
||||||
message: message,
|
message: message,
|
||||||
|
@ -140,9 +149,34 @@ class DefaultChatMessageBuilder extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ChatMessageStatus extends StatelessWidget {
|
||||||
|
const _ChatMessageStatus({
|
||||||
|
required this.messageTheme,
|
||||||
|
required this.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MessageTheme messageTheme;
|
||||||
|
final MessageStatus status;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => switch (status) {
|
||||||
|
MessageStatus.sending => Icon(
|
||||||
|
Icons.access_time,
|
||||||
|
size: 16.0,
|
||||||
|
color: messageTheme.textColor,
|
||||||
|
),
|
||||||
|
MessageStatus.sent => Icon(
|
||||||
|
Icons.check,
|
||||||
|
size: 16.0,
|
||||||
|
color: messageTheme.textColor,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class _ChatMessageBubble extends StatelessWidget {
|
class _ChatMessageBubble extends StatelessWidget {
|
||||||
const _ChatMessageBubble({
|
const _ChatMessageBubble({
|
||||||
required this.isSameSender,
|
required this.isSameSender,
|
||||||
|
required this.hasPreviousIndicator,
|
||||||
required this.isMessageFromSelf,
|
required this.isMessageFromSelf,
|
||||||
required this.message,
|
required this.message,
|
||||||
required this.previousMessage,
|
required this.previousMessage,
|
||||||
|
@ -154,6 +188,7 @@ class _ChatMessageBubble extends StatelessWidget {
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool isSameSender;
|
final bool isSameSender;
|
||||||
|
final bool hasPreviousIndicator;
|
||||||
final bool isMessageFromSelf;
|
final bool isMessageFromSelf;
|
||||||
final MessageModel message;
|
final MessageModel message;
|
||||||
final MessageModel? previousMessage;
|
final MessageModel? previousMessage;
|
||||||
|
@ -191,12 +226,15 @@ class _ChatMessageBubble extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
var messageTimeRow = Row(
|
var messageTimeRow = Padding(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
padding: const EdgeInsets.only(
|
||||||
children: [
|
bottom: 8.0,
|
||||||
Padding(
|
right: 8.0,
|
||||||
padding: const EdgeInsets.only(right: 8, bottom: 4),
|
),
|
||||||
child: CustomSemantics(
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
CustomSemantics(
|
||||||
identifier: semanticIdTime,
|
identifier: semanticIdTime,
|
||||||
value: messageTime,
|
value: messageTime,
|
||||||
child: Text(
|
child: Text(
|
||||||
|
@ -207,18 +245,28 @@ class _ChatMessageBubble extends StatelessWidget {
|
||||||
textAlign: TextAlign.end,
|
textAlign: TextAlign.end,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 4.0),
|
||||||
],
|
_ChatMessageStatus(
|
||||||
|
messageTheme: messageTheme,
|
||||||
|
status: message.status,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
var showName =
|
||||||
|
messageTheme.showName ?? (!isSameSender || hasPreviousIndicator);
|
||||||
|
|
||||||
|
var isNewSection = hasPreviousIndicator || showName;
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (messageTheme.showName ?? !isSameSender) ...[
|
if (isNewSection) ...[
|
||||||
SizedBox(height: options.spacing.chatBetweenMessagesPadding),
|
SizedBox(height: options.spacing.chatBetweenMessagesPadding),
|
||||||
senderTitleText,
|
|
||||||
],
|
],
|
||||||
|
if (showName) senderTitleText,
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
DefaultChatMessageContainer(
|
DefaultChatMessageContainer(
|
||||||
backgroundColor: messageTheme.backgroundColor!,
|
backgroundColor: messageTheme.backgroundColor!,
|
||||||
|
@ -232,6 +280,7 @@ class _ChatMessageBubble extends StatelessWidget {
|
||||||
_DefaultChatImage(
|
_DefaultChatImage(
|
||||||
message: message,
|
message: message,
|
||||||
messageTheme: messageTheme,
|
messageTheme: messageTheme,
|
||||||
|
options: options,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
],
|
],
|
||||||
|
@ -268,40 +317,121 @@ class _ChatMessageBubble extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DefaultChatImage extends StatelessWidget {
|
class _DefaultChatImage extends StatefulWidget {
|
||||||
const _DefaultChatImage({
|
const _DefaultChatImage({
|
||||||
required this.message,
|
required this.message,
|
||||||
required this.messageTheme,
|
required this.messageTheme,
|
||||||
|
required this.options,
|
||||||
});
|
});
|
||||||
|
|
||||||
final MessageModel message;
|
final MessageModel message;
|
||||||
|
final ChatOptions options;
|
||||||
final MessageTheme messageTheme;
|
final MessageTheme messageTheme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_DefaultChatImage> createState() => _DefaultChatImageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exception thrown when the image builder fails to recognize the image
|
||||||
|
class InvalidImageUrlException implements Exception {}
|
||||||
|
|
||||||
|
class _DefaultChatImageState extends State<_DefaultChatImage>
|
||||||
|
with AutomaticKeepAliveClientMixin {
|
||||||
|
late ImageProvider provider;
|
||||||
|
late Completer imageLoadingCompleter;
|
||||||
|
|
||||||
|
void _preloadImage() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
var uri = Uri.tryParse(widget.message.imageUrl ?? "");
|
||||||
|
if (uri == null) {
|
||||||
|
imageLoadingCompleter.completeError(InvalidImageUrlException());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
provider = widget.options.imageProviderResolver(
|
||||||
|
context,
|
||||||
|
uri,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
await precacheImage(
|
||||||
|
provider,
|
||||||
|
context,
|
||||||
|
onError: imageLoadingCompleter.completeError,
|
||||||
|
);
|
||||||
|
|
||||||
|
imageLoadingCompleter.complete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refreshImage() {
|
||||||
|
setState(() {
|
||||||
|
imageLoadingCompleter = Completer();
|
||||||
|
});
|
||||||
|
_preloadImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
imageLoadingCompleter = Completer();
|
||||||
|
_preloadImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant _DefaultChatImage oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.message.imageUrl != widget.message.imageUrl) {
|
||||||
|
_refreshImage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var chatScope = ChatScope.of(context);
|
super.build(context);
|
||||||
var options = chatScope.options;
|
|
||||||
var textTheme = Theme.of(context).textTheme;
|
var theme = Theme.of(context);
|
||||||
var imageUrl = message.imageUrl!;
|
|
||||||
|
var asyncImageBuilder = FutureBuilder<void>(
|
||||||
|
future: imageLoadingCompleter.future,
|
||||||
|
builder: (context, snapshot) => switch (snapshot.connectionState) {
|
||||||
|
ConnectionState.waiting => Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: widget.messageTheme.textColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ConnectionState.done when !snapshot.hasError => Image(
|
||||||
|
image: provider,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (context, error, stackTrace) =>
|
||||||
|
_DefaultMessageImageError(
|
||||||
|
messageTheme: widget.messageTheme,
|
||||||
|
onRefresh: _refreshImage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ => _DefaultMessageImageError(
|
||||||
|
messageTheme: widget.messageTheme,
|
||||||
|
onRefresh: _refreshImage,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ClipRRect(
|
child: LayoutBuilder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
builder: (context, constraints) => ConstrainedBox(
|
||||||
child: AnimatedSize(
|
constraints: BoxConstraints.tightForFinite(
|
||||||
duration: const Duration(milliseconds: 300),
|
width: constraints.maxWidth,
|
||||||
child: Image(
|
height: constraints.maxWidth,
|
||||||
image:
|
),
|
||||||
options.imageProviderResolver(context, Uri.parse(imageUrl)),
|
child: ClipRRect(
|
||||||
fit: BoxFit.fitWidth,
|
borderRadius: BorderRadius.circular(12),
|
||||||
errorBuilder: (context, error, stackTrace) => Text(
|
child: ColoredBox(
|
||||||
// TODO(Jacques): Non-replaceable text
|
color: widget.messageTheme.imageBackgroundColor ??
|
||||||
"Something went wrong with loading the image",
|
theme.colorScheme.secondaryContainer,
|
||||||
style: textTheme.bodyLarge?.copyWith(
|
child: asyncImageBuilder,
|
||||||
color: messageTheme.textColor,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -309,6 +439,30 @@ class _DefaultChatImage extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DefaultMessageImageError extends StatelessWidget {
|
||||||
|
const _DefaultMessageImageError({
|
||||||
|
required this.messageTheme,
|
||||||
|
required this.onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MessageTheme messageTheme;
|
||||||
|
final VoidCallback onRefresh;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Center(
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: onRefresh,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.refresh,
|
||||||
|
color: messageTheme.textColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A container for the chat message that provides a decoration around the
|
/// A container for the chat message that provides a decoration around the
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import "dart:typed_data";
|
import "dart:io";
|
||||||
|
|
||||||
|
import "package:flutter/foundation.dart";
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:flutter_accessibility/flutter_accessibility.dart";
|
import "package:flutter_accessibility/flutter_accessibility.dart";
|
||||||
import "package:flutter_chat/src/config/chat_options.dart";
|
import "package:flutter_chat/src/config/chat_options.dart";
|
||||||
import "package:flutter_chat/src/config/chat_translations.dart";
|
|
||||||
import "package:flutter_chat/src/util/scope.dart";
|
import "package:flutter_chat/src/util/scope.dart";
|
||||||
import "package:flutter_image_picker/flutter_image_picker.dart";
|
import "package:flutter_image_picker/flutter_image_picker.dart";
|
||||||
|
|
||||||
|
@ -16,15 +16,7 @@ Future<void> onPressSelectImage(
|
||||||
var image = await options.builders.imagePickerBuilder.call(context);
|
var image = await options.builders.imagePickerBuilder.call(context);
|
||||||
|
|
||||||
if (image == null) return;
|
if (image == null) return;
|
||||||
if (!context.mounted) return;
|
|
||||||
var messenger = ScaffoldMessenger.of(context)
|
|
||||||
..showSnackBar(
|
|
||||||
_getImageLoadingSnackbar(context, options.translations),
|
|
||||||
)
|
|
||||||
..activate();
|
|
||||||
await onUploadImage(image);
|
await onUploadImage(image);
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
|
||||||
messenger.hideCurrentSnackBar();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default image picker dialog for selecting an image from the gallery or
|
/// Default image picker dialog for selecting an image from the gallery or
|
||||||
|
@ -61,6 +53,7 @@ class DefaultImagePickerDialog extends StatelessWidget {
|
||||||
child: ImagePicker(
|
child: ImagePicker(
|
||||||
config: ImagePickerConfig(
|
config: ImagePickerConfig(
|
||||||
imageQuality: options.imageQuality.clamp(0, 100),
|
imageQuality: options.imageQuality.clamp(0, 100),
|
||||||
|
cameraOption: !kIsWeb && (Platform.isAndroid || Platform.isIOS),
|
||||||
),
|
),
|
||||||
theme: ImagePickerTheme(
|
theme: ImagePickerTheme(
|
||||||
spaceBetweenIcons: 32.0,
|
spaceBetweenIcons: 32.0,
|
||||||
|
@ -93,29 +86,3 @@ class DefaultImagePickerDialog extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SnackBar _getImageLoadingSnackbar(
|
|
||||||
BuildContext context,
|
|
||||||
ChatTranslations translations,
|
|
||||||
) {
|
|
||||||
var theme = Theme.of(context);
|
|
||||||
|
|
||||||
return SnackBar(
|
|
||||||
duration: const Duration(minutes: 1),
|
|
||||||
content: Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 25,
|
|
||||||
height: 25,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: theme.snackBarTheme.actionTextColor ?? Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 16.0),
|
|
||||||
child: Text(translations.imageUploading),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
name: flutter_chat
|
name: flutter_chat
|
||||||
description: "User story of the chat domain for quick integration into flutter apps"
|
description: "User story of the chat domain for quick integration into flutter apps"
|
||||||
version: 5.1.1
|
version: 6.0.0
|
||||||
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/
|
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
@ -26,7 +26,7 @@ dependencies:
|
||||||
version: ^1.6.0
|
version: ^1.6.0
|
||||||
chat_repository_interface:
|
chat_repository_interface:
|
||||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||||
version: ^5.1.0
|
version: ^6.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
Loading…
Reference in a new issue