Compare commits

..

No commits in common. "master" and "5.1.2" have entirely different histories.

24 changed files with 160 additions and 636 deletions

View file

@ -1,11 +1,3 @@
## 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 ## 5.1.2
- Added correct padding inbetween time indicators and names - Added correct padding inbetween time indicators and names
- Show names if a new day occurs and an indicator is shown - Show names if a new day occurs and an indicator is shown

View file

@ -2,11 +2,9 @@
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

@ -1,25 +0,0 @@
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,27 +73,11 @@ 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

@ -1,42 +0,0 @@
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,7 +3,6 @@ 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";
@ -208,13 +207,6 @@ 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,
@ -233,7 +225,6 @@ 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);
@ -280,7 +271,7 @@ class LocalChatRepository implements ChatRepositoryInterface {
required String chatId, required String chatId,
required String senderId, required String senderId,
}) => }) =>
Future.value(image.toDataUri()); Future.value("https://picsum.photos/200/300");
/// 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,9 +11,6 @@ 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

@ -1,90 +0,0 @@
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,24 +1,3 @@
/// 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.
@ -36,7 +15,6 @@ 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
@ -49,7 +27,6 @@ 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
@ -73,9 +50,6 @@ 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,
@ -85,7 +59,6 @@ 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,
@ -95,7 +68,6 @@ 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
@ -106,11 +78,7 @@ 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,18 +1,14 @@
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.
@ -22,12 +18,9 @@ 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;
@ -35,9 +28,6 @@ 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;
@ -145,34 +135,11 @@ 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,
}) { }) =>
List<MessageModel> mergePendingMessages( chatRepository.getMessages(
List<MessageModel> messages, userId: userId,
List<MessageModel> pendingMessages, chatId: chatId,
) => );
{
...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.
@ -202,73 +169,19 @@ class ChatService {
Future<void> sendMessage({ Future<void> sendMessage({
required String chatId, required String chatId,
required String senderId, required String senderId,
String? presetMessageId, required String messageId,
String? text, String? text,
String? messageType, String? messageType,
String? imageUrl, String? imageUrl,
Uint8List? imageData, }) =>
}) async { chatRepository.sendMessage(
var messageId = presetMessageId ?? chatId: chatId,
await chatRepository.getNextMessageId(userId: userId, chatId: chatId); messageId: messageId,
text: text,
await pendingMessageRepository.createMessage( messageType: messageType,
chatId: chatId, senderId: senderId,
senderId: senderId, imageUrl: imageUrl,
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.

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: 6.0.0 version: 5.1.2
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,7 +9,6 @@ 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,13 +140,6 @@ 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: 6.0.0 version: 5.1.2
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: ^6.0.0 version: ^5.1.0
firebase_storage: any firebase_storage: any
cloud_firestore: any cloud_firestore: any

View file

@ -19,19 +19,18 @@ class MyApp extends StatelessWidget {
useMaterial3: true, useMaterial3: true,
), ),
home: const MyHomePage(), home: const MyHomePage(),
// home: FutureBuilder( // 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();
// }),
); );
} }
} }
@ -46,8 +45,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

View file

@ -29,19 +29,12 @@ 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;
@ -154,7 +147,6 @@ 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,
@ -171,7 +163,6 @@ 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,
@ -210,12 +201,6 @@ 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;
@ -246,7 +231,6 @@ 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,
@ -261,7 +245,6 @@ 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,
@ -279,8 +262,6 @@ 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,
@ -303,10 +284,7 @@ ImageProvider _defaultImageProviderResolver(
BuildContext context, BuildContext context,
Uri image, Uri image,
) => ) =>
switch (image.scheme) { CachedNetworkImageProvider(image.toString());
"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

@ -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.abs()} days ago", int value when value < -1 && value > -5 => "$value days ago",
_ => DateFormat("dd-MM-YYYY").format(time), _ => DateFormat("dd-MM-YYYY").format(time),
}; };

View file

@ -83,7 +83,6 @@ 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,7 +94,6 @@ 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,16 +50,27 @@ 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 => chatService.sendImageMessage( onUploadImage: (data) async {
chatId: chatId, var path = await chatService.uploadImage(
userId: userId, path: "chats/$chatId-$userId-${DateTime.now()}",
data: data, image: data,
), chatId: chatId,
onMessageSubmit: (text) async => chatService.sendMessage( );
chatId: chatId, await chatService.sendMessage(
senderId: userId, messageId: "$chatId-$userId-${DateTime.now()}",
text: text, chatId: chatId,
), 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(

View file

@ -497,27 +497,19 @@ 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( Expanded(child: options.builders.loadingWidgetBuilder.call(context)),
child: _CloseKeyboardOnTap(
child: options.builders.loadingWidgetBuilder.call(context),
),
),
] else ...[ ] else ...[
Expanded( Expanded(
child: _CloseKeyboardOnTap( child: ListView.builder(
child: messageList, reverse: false,
controller: scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(top: 24),
itemCount: listViewChildren.length,
itemBuilder: (context, index) => listViewChildren[index],
), ),
), ),
], ],
@ -527,26 +519,6 @@ 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,37 +63,34 @@ class ChatBottomInputSection extends HookWidget {
} }
/// Image and send buttons /// Image and send buttons
var messageSendButtons = Padding( var messageSendButtons = SizedBox(
padding: const EdgeInsets.only(right: 6.0), height: 45,
child: SizedBox( child: Row(
height: 48, crossAxisAlignment: CrossAxisAlignment.center,
child: Row( mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, children: [
mainAxisSize: MainAxisSize.min, CustomSemantics(
children: [ identifier: options.semantics.chatSelectImageIconButton,
CustomSemantics( child: IconButton(
identifier: options.semantics.chatSelectImageIconButton, alignment: Alignment.bottomRight,
child: IconButton( onPressed: isLoading ? null : onPressSelectImage,
alignment: Alignment.bottomRight, icon: Icon(
onPressed: isLoading ? null : onPressSelectImage, Icons.image_outlined,
icon: Icon(
Icons.image_outlined,
color: options.iconEnabledColor,
),
),
),
CustomSemantics(
identifier: options.semantics.chatSendMessageIconButton,
child: IconButton(
alignment: Alignment.bottomRight,
disabledColor: options.iconDisabledColor,
color: options.iconEnabledColor, color: options.iconEnabledColor,
onPressed: isLoading ? null : onClickSendMessage,
icon: const Icon(Icons.send_rounded),
), ),
), ),
], ),
), CustomSemantics(
identifier: options.semantics.chatSendMessageIconButton,
child: IconButton(
alignment: Alignment.bottomRight,
disabledColor: options.iconDisabledColor,
color: options.iconEnabledColor,
onPressed: isLoading ? null : onClickSendMessage,
icon: const Icon(Icons.send_rounded),
),
),
],
), ),
); );
@ -130,12 +127,10 @@ 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: ExcludeFocus( suffixIcon: AbsorbPointer(
child: AbsorbPointer( child: Opacity(
child: Opacity( opacity: 0.0,
opacity: 0.0, child: messageSendButtons,
child: messageSendButtons,
),
), ),
), ),
hintText: options.translations.messagePlaceholder, hintText: options.translations.messagePlaceholder,

View file

@ -1,5 +1,3 @@
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";
@ -149,30 +147,6 @@ 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,
@ -226,15 +200,12 @@ class _ChatMessageBubble extends StatelessWidget {
), ),
); );
var messageTimeRow = Padding( var messageTimeRow = Row(
padding: const EdgeInsets.only( mainAxisAlignment: MainAxisAlignment.end,
bottom: 8.0, children: [
right: 8.0, Padding(
), padding: const EdgeInsets.only(right: 8, bottom: 4),
child: Row( child: CustomSemantics(
mainAxisAlignment: MainAxisAlignment.end,
children: [
CustomSemantics(
identifier: semanticIdTime, identifier: semanticIdTime,
value: messageTime, value: messageTime,
child: Text( child: Text(
@ -245,13 +216,8 @@ class _ChatMessageBubble extends StatelessWidget {
textAlign: TextAlign.end, textAlign: TextAlign.end,
), ),
), ),
const SizedBox(width: 4.0), ),
_ChatMessageStatus( ],
messageTheme: messageTheme,
status: message.status,
),
],
),
); );
var showName = var showName =
@ -280,7 +246,6 @@ class _ChatMessageBubble extends StatelessWidget {
_DefaultChatImage( _DefaultChatImage(
message: message, message: message,
messageTheme: messageTheme, messageTheme: messageTheme,
options: options,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
], ],
@ -317,121 +282,40 @@ class _ChatMessageBubble extends StatelessWidget {
} }
} }
class _DefaultChatImage extends StatefulWidget { class _DefaultChatImage extends StatelessWidget {
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) {
super.build(context); var chatScope = ChatScope.of(context);
var options = chatScope.options;
var theme = Theme.of(context); var textTheme = Theme.of(context).textTheme;
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( child: ClipRRect(
builder: (context, constraints) => ConstrainedBox( borderRadius: BorderRadius.circular(12),
constraints: BoxConstraints.tightForFinite( child: AnimatedSize(
width: constraints.maxWidth, duration: const Duration(milliseconds: 300),
height: constraints.maxWidth, child: Image(
), image:
child: ClipRRect( options.imageProviderResolver(context, Uri.parse(imageUrl)),
borderRadius: BorderRadius.circular(12), fit: BoxFit.fitWidth,
child: ColoredBox( errorBuilder: (context, error, stackTrace) => Text(
color: widget.messageTheme.imageBackgroundColor ?? // TODO(Jacques): Non-replaceable text
theme.colorScheme.secondaryContainer, "Something went wrong with loading the image",
child: asyncImageBuilder, style: textTheme.bodyLarge?.copyWith(
color: messageTheme.textColor,
),
), ),
), ),
), ),
@ -439,30 +323,6 @@ class _DefaultChatImageState extends State<_DefaultChatImage>
), ),
); );
} }
@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:io"; import "dart:typed_data";
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,7 +16,15 @@ 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
@ -53,7 +61,6 @@ 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,
@ -86,3 +93,29 @@ 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,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: 6.0.0 version: 5.1.2
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: ^6.0.0 version: ^5.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: