Compare commits

...

20 commits

Author SHA1 Message Date
f7f15ef750 docs: add more descriptive documentation to repository methods 2025-03-12 11:39:22 +01:00
c155531b11 chore: update version and changelog for version 6.0.0 2025-03-12 11:39:22 +01:00
Kiril Tijsma
90610caabd fix(local/pending-message-repository): remove chunking 2025-03-12 11:39:22 +01:00
Kiril Tijsma
e1e23e7b35 fix(chat-service): sort combined streams 2025-03-12 11:39:22 +01:00
9b365d573d feat: add tap to close keyboard on chat detail screen 2025-03-12 11:39:22 +01:00
3b4b456db2 chore: use proper indentation on commented code in example 2025-03-12 11:39:22 +01:00
Kiril Tijsma
ad615133e4 feat(chat-options): add pending messages option 2025-03-12 11:39:22 +01:00
f286e7fb79 feat: add loading and refresh handling for images 2025-03-12 11:39:22 +01:00
3f1caa912b feat: remove camera on unsupported platforms for default image picker 2025-03-12 11:39:22 +01:00
7d634e54c1 feat: remove snackbar when uploading an image according to UI 2025-03-12 11:39:22 +01:00
Kiril Tijsma
3fbcf5d076 feat(chat-service/pending-messages): add pending images 2025-03-12 11:39:22 +01:00
84cc630c6e fix: export pending messages repository interface and local implementation 2025-03-12 11:39:22 +01:00
Kiril Tijsma
3cec2ee1c6 fix(message-date-label): take absolute value to prevent -x days ago 2025-03-12 11:39:22 +01:00
Kiril Tijsma
b8e22425a1 feat(chat-message-bubble): add status marker 2025-03-12 11:39:22 +01:00
Kiril Tijsma
61b588cfd5 feat(chat-service): add pending messages repository 2025-03-12 11:39:22 +01:00
Kiril Tijsma
02ae2aa884 fix(message-send-buttons): move slightly to better match design 2025-03-12 11:39:22 +01:00
Kiril Tijsma
d2f000c8a7 fix(message-send-buttons): prevent focus on hidden elements 2025-03-12 11:39:22 +01:00
52562746b6 fix: show names with correct padding if an indicator is used 2025-03-07 10:16:17 +01:00
a48806fe98 fix: expose default chat indicator builder 2025-03-06 17:02:47 +01:00
bcf2c0484b feat(chat-time-indicator): add small time-indicator in chat detail screens 2025-03-06 16:46:03 +01:00
30 changed files with 929 additions and 173 deletions

View file

@ -1,3 +1,21 @@
## 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
- Expose default indicator builder from the indicator options
## 5.1.0
- Added optional time indicator in chat detail screens to show which day the message is posted
## 5.0.0 ## 5.0.0
- Removed the default values for the ChatOptions that are now nullable so they resolve to the ThemeData values - Removed the default values for the ChatOptions that are now nullable so they resolve to the ThemeData values
- Added chatAlignment to change the alignment of the chat messages - Added chatAlignment to change the alignment of the chat messages

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,12 +145,35 @@ 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.
Future<void> loadNewMessagesAfter({ Future<void> loadNewMessagesAfter({
@ -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 {
var messageId = presetMessageId ??
await chatRepository.getNextMessageId(userId: userId, chatId: chatId);
await pendingMessageRepository.createMessage(
chatId: chatId,
senderId: senderId,
messageId: messageId,
text: text,
messageType: messageType,
imageUrl: imageData?.toDataUri() ?? imageUrl,
);
unawaited(
chatRepository
.sendMessage(
chatId: chatId, chatId: chatId,
messageId: messageId, messageId: messageId,
text: text, text: text,
messageType: messageType, messageType: messageType,
senderId: senderId, senderId: senderId,
imageUrl: imageUrl, 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.

View file

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

View file

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

View file

@ -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.0.0 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.0.0 version: ^6.0.0
firebase_storage: any firebase_storage: any
cloud_firestore: any cloud_firestore: any

View file

@ -19,7 +19,7 @@ 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,
// ), // ),
@ -30,7 +30,8 @@ class MyApp extends StatelessWidget {
// ); // );
// } // }
// return const MyHomePage(); // return const MyHomePage();
// }), // },
// ),
); );
} }
} }

View file

@ -8,6 +8,7 @@ export "package:flutter_chat/src/flutter_chat_navigator_userstories.dart";
// Options // Options
export "src/config/chat_builders.dart"; export "src/config/chat_builders.dart";
export "src/config/chat_options.dart"; export "src/config/chat_options.dart";
export "src/config/chat_time_indicator_options.dart";
export "src/config/chat_translations.dart"; export "src/config/chat_translations.dart";
export "src/config/screen_types.dart"; export "src/config/screen_types.dart";

View file

@ -1,9 +1,7 @@
import "package:cached_network_image/cached_network_image.dart"; import "package:cached_network_image/cached_network_image.dart";
import "package:chat_repository_interface/chat_repository_interface.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_chat/src/config/chat_builders.dart"; import "package:flutter_chat/flutter_chat.dart";
import "package:flutter_chat/src/config/chat_semantics.dart"; import "package:flutter_chat/src/config/chat_semantics.dart";
import "package:flutter_chat/src/config/chat_translations.dart";
/// The chat options /// The chat options
/// Use this class to configure the chat options. /// Use this class to configure the chat options.
@ -28,14 +26,22 @@ class ChatOptions {
this.onNoChats, this.onNoChats,
this.imageQuality = 20, this.imageQuality = 20,
this.imageProviderResolver = _defaultImageProviderResolver, this.imageProviderResolver = _defaultImageProviderResolver,
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;
@ -109,6 +115,9 @@ class ChatOptions {
/// the images in the entire userstory. If not provided, CachedNetworkImage /// the images in the entire userstory. If not provided, CachedNetworkImage
/// will be used. /// will be used.
final ImageProviderResolver imageProviderResolver; final ImageProviderResolver imageProviderResolver;
/// Options regarding the time indicator in chat screens
final ChatTimeIndicatorOptions timeIndicatorOptions;
} }
/// Typedef for the chatTitleResolver function that is used to get a title for /// Typedef for the chatTitleResolver function that is used to get a title for
@ -145,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,
@ -161,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,
@ -199,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;
@ -229,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,
@ -243,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,
@ -260,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,
@ -282,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 {

View file

@ -0,0 +1,105 @@
import "package:flutter/material.dart";
import "package:flutter_chat/flutter_chat.dart";
export "package:flutter_chat/src/screens/chat_detail/widgets/default_chat_time_indicator.dart";
/// All options related to the time indicator
class ChatTimeIndicatorOptions {
/// Create default ChatTimeIndicator options
const ChatTimeIndicatorOptions({
this.indicatorBuilder = DefaultChatTimeIndicator.builder,
this.labelResolver = defaultChatTimeIndicatorLabelResolver,
this.sectionCheck = defaultChatTimeIndicatorSectionChecker,
});
/// This completely disables the chat time indicator feature
const ChatTimeIndicatorOptions.none()
: indicatorBuilder = DefaultChatTimeIndicator.builder,
labelResolver = defaultChatTimeIndicatorLabelResolver,
sectionCheck = neverShowChatTimeIndicatorSectionChecker;
/// The general builder for the indicator
final ChatTimeIndicatorBuilder indicatorBuilder;
/// A function that translates offset / time to a string label
final ChatTimeIndicatorLabelResolver labelResolver;
/// A function that determines when a new section starts
///
/// By default, all messages are prefixed with a message.
/// You can disable this using the [skipFirstChatTimeIndicatorSectionChecker]
/// instead of the default, which would skip the first section
final ChatTimeIndicatorSectionChecker sectionCheck;
/// public method on the options for readability
bool isMessageInNewTimeSection(
BuildContext context,
MessageModel? previousMessage,
MessageModel currentMessage,
) =>
sectionCheck(
context,
previousMessage,
currentMessage,
);
}
/// A function that would generate a string given the current window/datetime
typedef ChatTimeIndicatorLabelResolver = String Function(
BuildContext context,
int dayOffset,
DateTime currentWindow,
);
/// A function that would determine if a chat indicator has to render
typedef ChatTimeIndicatorSectionChecker = bool Function(
BuildContext context,
MessageModel? previousMessage,
MessageModel currentMessage,
);
/// Build used to render time indicators on chat detail screens
typedef ChatTimeIndicatorBuilder = Widget Function(
BuildContext context,
String timeLabel,
);
///
String defaultChatTimeIndicatorLabelResolver(
BuildContext context,
int dayOffset,
DateTime currentWindow,
) {
var translations = ChatScope.of(context).options.translations;
return translations.chatTimeIndicatorLabel(dayOffset, currentWindow);
}
/// A function that disables the time indicator in chat
bool neverShowChatTimeIndicatorSectionChecker(
BuildContext context,
MessageModel? previousMessage,
MessageModel currentMessage,
) =>
false;
/// Variant of the default implementation for determining if a new section
/// starts, where the first section is skipped.
///
/// Renders a new indicator every new section, skipping the first section
bool skipFirstChatTimeIndicatorSectionChecker(
BuildContext context,
MessageModel? previousMessage,
MessageModel currentMessage,
) =>
previousMessage != null &&
previousMessage.timestamp.date.isBefore(currentMessage.timestamp.date);
/// Default implementation for determining if a new section starts.
///
/// Renders a new indicator every new section
bool defaultChatTimeIndicatorSectionChecker(
BuildContext context,
MessageModel? previousMessage,
MessageModel currentMessage,
) =>
previousMessage == null ||
previousMessage.timestamp.date.isBefore(currentMessage.timestamp.date);

View file

@ -4,6 +4,8 @@
// ignore_for_file: public_member_api_docs // ignore_for_file: public_member_api_docs
import "package:intl/intl.dart";
/// Class that holds all the translations for the chat component view and /// Class that holds all the translations for the chat component view and
/// the corresponding userstory /// the corresponding userstory
class ChatTranslations { class ChatTranslations {
@ -50,6 +52,7 @@ class ChatTranslations {
required this.groupNameEmpty, required this.groupNameEmpty,
required this.messagesLoadingError, required this.messagesLoadingError,
required this.next, required this.next,
required this.chatTimeIndicatorLabel,
}); });
/// Default translations for the chat component view /// Default translations for the chat component view
@ -95,6 +98,8 @@ class ChatTranslations {
this.groupNameEmpty = "Group", this.groupNameEmpty = "Group",
this.messagesLoadingError = "Error loading messages, you can reload below:", this.messagesLoadingError = "Error loading messages, you can reload below:",
this.next = "Next", this.next = "Next",
this.chatTimeIndicatorLabel =
ChatTranslations.defaultChatTimeIndicatorLabel,
}); });
final String chatsTitle; final String chatsTitle;
@ -140,6 +145,33 @@ class ChatTranslations {
/// to be loaded. /// to be loaded.
final String messagesLoadingError; final String messagesLoadingError;
/// The message of a label given a certain offset.
///
/// The offset determines whether it is today (0), yesterday (-1), or earlier.
///
/// [dateOffset] will rarely be a +1, however if anyone ever wants to see
/// future chat messages, then this number will be positive.
///
/// use the given [time] format to display exact time information.
final String Function(int dateOffset, DateTime time) chatTimeIndicatorLabel;
/// Standard function to convert an offset to a String.
///
/// Recommended to always override this in any production app with an
/// app localizations implementation.
static String defaultChatTimeIndicatorLabel(
int dateOffset,
DateTime time,
) =>
switch (dateOffset) {
0 => "Today",
-1 => "Yesterday",
1 => "Tomorrow",
int value when value < 5 && value > 1 => "In $value days",
int value when value < -1 && value > -5 => "${value.abs()} days ago",
_ => DateFormat("dd-MM-YYYY").format(time),
};
final String next; final String next;
// copyWith method to override the default values // copyWith method to override the default values
@ -182,6 +214,7 @@ class ChatTranslations {
String? groupNameEmpty, String? groupNameEmpty,
String? messagesLoadingError, String? messagesLoadingError,
String? next, String? next,
String Function(int dateOffset, DateTime time)? chatTimeIndicatorLabel,
}) => }) =>
ChatTranslations( ChatTranslations(
chatsTitle: chatsTitle ?? this.chatsTitle, chatsTitle: chatsTitle ?? this.chatsTitle,
@ -234,5 +267,7 @@ class ChatTranslations {
groupNameEmpty: groupNameEmpty ?? this.groupNameEmpty, groupNameEmpty: groupNameEmpty ?? this.groupNameEmpty,
messagesLoadingError: messagesLoadingError ?? this.messagesLoadingError, messagesLoadingError: messagesLoadingError ?? this.messagesLoadingError,
next: next ?? this.next, next: next ?? this.next,
chatTimeIndicatorLabel:
chatTimeIndicatorLabel ?? this.chatTimeIndicatorLabel,
); );
} }

View file

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

View file

@ -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],
); );

View file

@ -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(
path: "chats/$chatId-$userId-${DateTime.now()}",
image: data,
chatId: chatId, chatId: chatId,
); userId: userId,
await chatService.sendMessage( data: data,
messageId: "$chatId-$userId-${DateTime.now()}", ),
chatId: chatId, onMessageSubmit: (text) async => chatService.sendMessage(
senderId: userId,
imageUrl: path,
);
},
onMessageSubmit: (text) async {
await chatService.sendMessage(
messageId: "$chatId-$userId-${DateTime.now()}",
chatId: chatId, chatId: chatId,
senderId: userId, senderId: userId,
text: text, text: text,
); ),
},
onPressChatTitle: (chat) async { onPressChatTitle: (chat) async {
if (chat.isGroupChat) { if (chat.isGroupChat) {
await _routeToScreen( await _routeToScreen(

View file

@ -1,15 +1,12 @@
import "dart:async"; import "dart:async";
import "dart:typed_data"; import "dart:typed_data";
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";
import "package:flutter_chat/src/config/chat_options.dart"; import "package:flutter_chat/flutter_chat.dart";
import "package:flutter_chat/src/config/screen_types.dart";
import "package:flutter_chat/src/screens/chat_detail/widgets/chat_bottom.dart"; import "package:flutter_chat/src/screens/chat_detail/widgets/chat_bottom.dart";
import "package:flutter_chat/src/screens/chat_detail/widgets/chat_widgets.dart"; import "package:flutter_chat/src/screens/chat_detail/widgets/chat_widgets.dart";
import "package:flutter_chat/src/screens/creation/widgets/default_image_picker.dart"; import "package:flutter_chat/src/screens/creation/widgets/default_image_picker.dart";
import "package:flutter_chat/src/util/scope.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
/// Chat detail screen /// Chat detail screen
@ -465,14 +462,26 @@ class _ChatBody extends HookWidget {
bubbleChildren bubbleChildren
.add(ChatNoMessages(isGroupChat: chat?.isGroupChat ?? false)); .add(ChatNoMessages(isGroupChat: chat?.isGroupChat ?? false));
} else { } else {
for (var (index, msg) in messages.indexed) { for (var (index, currentMessage) in messages.indexed) {
var prevMsg = index > 0 ? messages[index - 1] : null; var previousMessage = index > 0 ? messages[index - 1] : null;
if (options.timeIndicatorOptions.isMessageInNewTimeSection(
context,
previousMessage,
currentMessage,
)) {
bubbleChildren.add(
ChatTimeIndicator(
forDate: currentMessage.timestamp,
),
);
}
bubbleChildren.add( bubbleChildren.add(
ChatBubble( ChatBubble(
message: msg, message: currentMessage,
previousMessage: prevMsg, previousMessage: previousMessage,
sender: userMap[msg.senderId], sender: userMap[currentMessage.senderId],
onPressSender: onPressUserProfile, onPressSender: onPressUserProfile,
semanticIdTitle: options.semantics.chatBubbleTitle(index), semanticIdTitle: options.semantics.chatBubbleTitle(index),
semanticIdTime: options.semantics.chatBubbleTime(index), semanticIdTime: options.semantics.chatBubbleTime(index),
@ -488,19 +497,27 @@ class _ChatBody extends HookWidget {
bottomSpinner, bottomSpinner,
]; ];
return Column( var messageList = ListView.builder(
children: [
if (chatIsLoading && options.enableLoadingIndicator) ...[
Expanded(child: options.builders.loadingWidgetBuilder.call(context)),
] else ...[
Expanded(
child: ListView.builder(
reverse: false, reverse: false,
controller: scrollController, controller: scrollController,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(top: 24), padding: const EdgeInsets.only(top: 24),
itemCount: listViewChildren.length, itemCount: listViewChildren.length,
itemBuilder: (context, index) => listViewChildren[index], itemBuilder: (context, index) => listViewChildren[index],
);
return Column(
children: [
if (chatIsLoading && options.enableLoadingIndicator) ...[
Expanded(
child: _CloseKeyboardOnTap(
child: options.builders.loadingWidgetBuilder.call(context),
),
),
] else ...[
Expanded(
child: _CloseKeyboardOnTap(
child: messageList,
), ),
), ),
], ],
@ -510,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

View file

@ -63,8 +63,10 @@ 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: SizedBox(
height: 48,
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -92,6 +94,7 @@ class ChatBottomInputSection extends HookWidget {
), ),
], ],
), ),
),
); );
Future<void> onSubmitField() async => sendMessage(); Future<void> onSubmitField() async => sendMessage();
@ -127,12 +130,14 @@ 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: AbsorbPointer(
child: Opacity( child: Opacity(
opacity: 0.0, opacity: 0.0,
child: messageSendButtons, child: messageSendButtons,
), ),
), ),
),
hintText: options.translations.messagePlaceholder, hintText: options.translations.messagePlaceholder,
hintStyle: theme.textTheme.bodyMedium, hintStyle: theme.textTheme.bodyMedium,
fillColor: Colors.white, fillColor: Colors.white,

View file

@ -3,6 +3,7 @@ import "package:flutter/material.dart";
import "package:flutter_accessibility/flutter_accessibility.dart"; import "package:flutter_accessibility/flutter_accessibility.dart";
import "package:flutter_chat/src/screens/chat_detail/widgets/default_message_builder.dart"; import "package:flutter_chat/src/screens/chat_detail/widgets/default_message_builder.dart";
import "package:flutter_chat/src/util/scope.dart"; import "package:flutter_chat/src/util/scope.dart";
import "package:flutter_chat/src/util/utils.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
/// Widget displayed when there are no messages in the chat. /// Widget displayed when there are no messages in the chat.
@ -102,3 +103,32 @@ class ChatBubble extends HookWidget {
); );
} }
} }
/// The indicator above a set of messages, shown per date.
class ChatTimeIndicator extends StatelessWidget {
/// Creates a ChatTimeIndicator
const ChatTimeIndicator({
required this.forDate,
super.key,
});
/// The dateTime at which the new time section starts
final DateTime forDate;
@override
Widget build(BuildContext context) {
var scope = ChatScope.of(context);
var indicatorOptions = scope.options.timeIndicatorOptions;
var today = DateTime.now();
var differenceInDays = today.getDateOffsetInDays(forDate);
var message = indicatorOptions.labelResolver(
context,
differenceInDays,
forDate,
);
return indicatorOptions.indicatorBuilder(context, message);
}
}

View file

@ -0,0 +1,43 @@
import "package:flutter/material.dart";
import "package:flutter_chat/flutter_chat.dart";
/// The default layout for a chat indicator
class DefaultChatTimeIndicator extends StatelessWidget {
/// Create a default timeindicator in a chat
const DefaultChatTimeIndicator({
required this.timeIndicatorString,
super.key,
});
/// The text shown in the time indicator
final String timeIndicatorString;
/// Standard builder for time indication
static Widget builder(BuildContext context, String timeIndicatorString) =>
DefaultChatTimeIndicator(timeIndicatorString: timeIndicatorString);
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
var spacing = ChatScope.of(context).options.spacing;
return Center(
child: Container(
margin: EdgeInsets.only(top: spacing.chatBetweenMessagesPadding),
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 8,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: theme.colorScheme.surfaceContainerHighest,
),
child: Text(
timeIndicatorString,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
);
}
}

View file

@ -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(
padding: const EdgeInsets.only(
bottom: 8.0,
right: 8.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Padding( CustomSemantics(
padding: const EdgeInsets.only(right: 8, bottom: 4),
child: 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: LayoutBuilder(
builder: (context, constraints) => ConstrainedBox(
constraints: BoxConstraints.tightForFinite(
width: constraints.maxWidth,
height: constraints.maxWidth,
),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: AnimatedSize( child: ColoredBox(
duration: const Duration(milliseconds: 300), color: widget.messageTheme.imageBackgroundColor ??
child: Image( theme.colorScheme.secondaryContainer,
image: child: asyncImageBuilder,
options.imageProviderResolver(context, Uri.parse(imageUrl)),
fit: BoxFit.fitWidth,
errorBuilder: (context, error, stackTrace) => Text(
// TODO(Jacques): Non-replaceable text
"Something went wrong with loading the image",
style: textTheme.bodyLarge?.copyWith(
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

View file

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

View file

@ -1 +1,18 @@
// add generic utils that are used in the package // add generic utils that are used in the package
/// Extension to simplify detecting how many days relative dates are
extension RelativeDates on DateTime {
/// Strips timezone information whilst keeping the exact same date
DateTime get utcDate => DateTime.utc(year, month, day);
/// Strips time information from the date
DateTime get date => DateTime(year, month, day);
/// Get relative date in offset from the current position.
///
/// `today.getDateOffsetInDays(yesterday)` would result in `-1`
///
/// `yesterday.getDateOffsetInDays(tomorrow)` would result in `2`
int getDateOffsetInDays(DateTime other) =>
other.utcDate.difference(utcDate).inDays;
}

View file

@ -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.0.0 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.0.0 version: ^6.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View file

@ -0,0 +1,14 @@
import "package:flutter_chat/src/util/utils.dart";
import "package:flutter_test/flutter_test.dart";
void main() {
group("RelativeDates", () {
test("getDateOffsetInDays", () {
var dateA = DateTime(2024, 10, 30);
var dateB = DateTime(2024, 10, 01);
expect(dateA.getDateOffsetInDays(dateB), equals(29));
expect(dateB.getDateOffsetInDays(dateA), equals(-29));
});
});
}