mirror of
https://github.com/Iconica-Development/flutter_chat.git
synced 2025-05-19 10:53:51 +02:00
Compare commits
No commits in common. "master" and "5.0.0" have entirely different histories.
30 changed files with 173 additions and 929 deletions
18
CHANGELOG.md
18
CHANGELOG.md
|
@ -1,21 +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
|
||||
- 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
|
||||
- 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
|
||||
|
|
|
@ -2,11 +2,9 @@
|
|||
export "src/exceptions/chat.dart";
|
||||
// Interfaces
|
||||
export "src/interfaces/chat_repostory_interface.dart";
|
||||
export "src/interfaces/pending_message_repository_interface.dart";
|
||||
export "src/interfaces/user_repository_interface.dart";
|
||||
// Local implementations
|
||||
export "src/local/local_chat_repository.dart";
|
||||
export "src/local/local_pending_message_repository.dart";
|
||||
export "src/local/local_user_repository.dart";
|
||||
// Models
|
||||
export "src/models/chat_model.dart";
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -73,27 +73,11 @@ abstract class ChatRepositoryInterface {
|
|||
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.
|
||||
///
|
||||
/// [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<void> sendMessage({
|
||||
required String chatId,
|
||||
required String senderId,
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -3,7 +3,6 @@ import "dart:math" as math;
|
|||
import "dart:typed_data";
|
||||
|
||||
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:collection/collection.dart";
|
||||
import "package:rxdart/rxdart.dart";
|
||||
|
@ -208,13 +207,6 @@ class LocalChatRepository implements ChatRepositoryInterface {
|
|||
return Stream.value(message);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getNextMessageId({
|
||||
required String userId,
|
||||
required String chatId,
|
||||
}) async =>
|
||||
"$chatId-$userId-${DateTime.now()}";
|
||||
|
||||
@override
|
||||
Future<void> sendMessage({
|
||||
required String chatId,
|
||||
|
@ -233,7 +225,6 @@ class LocalChatRepository implements ChatRepositoryInterface {
|
|||
messageType: messageType,
|
||||
senderId: senderId,
|
||||
imageUrl: imageUrl,
|
||||
status: MessageStatus.sent,
|
||||
);
|
||||
|
||||
var chat = chats.firstWhereOrNull((e) => e.id == chatId);
|
||||
|
@ -280,7 +271,7 @@ class LocalChatRepository implements ChatRepositoryInterface {
|
|||
required String chatId,
|
||||
required String senderId,
|
||||
}) =>
|
||||
Future.value(image.toDataUri());
|
||||
Future.value("https://picsum.photos/200/300");
|
||||
|
||||
/// All the chats of the local memory database
|
||||
List<ChatModel> get getLocalChats => chats;
|
||||
|
|
|
@ -11,9 +11,6 @@ final List<ChatModel> chats = [];
|
|||
/// All the messages of the local memory database mapped by chat id
|
||||
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
|
||||
final List<UserModel> users = [
|
||||
const UserModel(
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
/// Represents a message in a chat
|
||||
/// [id] is the message id.
|
||||
|
@ -36,7 +15,6 @@ class MessageModel {
|
|||
required this.imageUrl,
|
||||
required this.timestamp,
|
||||
required this.senderId,
|
||||
this.status = MessageStatus.sent,
|
||||
});
|
||||
|
||||
/// Creates a message model instance given a map instance
|
||||
|
@ -49,7 +27,6 @@ class MessageModel {
|
|||
imageUrl: map["imageUrl"],
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(map["timestamp"]),
|
||||
senderId: map["senderId"],
|
||||
status: MessageStatus.tryParse(map["status"]) ?? MessageStatus.sent,
|
||||
);
|
||||
|
||||
/// The chat id
|
||||
|
@ -73,9 +50,6 @@ class MessageModel {
|
|||
/// The sender id
|
||||
final String senderId;
|
||||
|
||||
/// The message status
|
||||
final MessageStatus status;
|
||||
|
||||
/// The message model copy with method
|
||||
MessageModel copyWith({
|
||||
String? chatId,
|
||||
|
@ -85,7 +59,6 @@ class MessageModel {
|
|||
String? imageUrl,
|
||||
DateTime? timestamp,
|
||||
String? senderId,
|
||||
MessageStatus? status,
|
||||
}) =>
|
||||
MessageModel(
|
||||
chatId: chatId ?? this.chatId,
|
||||
|
@ -95,7 +68,6 @@ class MessageModel {
|
|||
imageUrl: imageUrl ?? this.imageUrl,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
senderId: senderId ?? this.senderId,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
|
||||
/// Creates a map representation of this object
|
||||
|
@ -106,11 +78,7 @@ class MessageModel {
|
|||
"imageUrl": imageUrl,
|
||||
"timestamp": timestamp.millisecondsSinceEpoch,
|
||||
"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
|
||||
|
|
|
@ -1,18 +1,14 @@
|
|||
import "dart:async";
|
||||
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/pending_message_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_pending_message_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/message_model.dart";
|
||||
import "package:chat_repository_interface/src/models/user_model.dart";
|
||||
import "package:collection/collection.dart";
|
||||
import "package:rxdart/rxdart.dart";
|
||||
|
||||
/// The chat service
|
||||
/// Use this service to interact with the chat repository.
|
||||
|
@ -22,12 +18,9 @@ class ChatService {
|
|||
ChatService({
|
||||
required this.userId,
|
||||
ChatRepositoryInterface? chatRepository,
|
||||
PendingMessageRepositoryInterface? pendingMessageRepository,
|
||||
UserRepositoryInterface? userRepository,
|
||||
}) : chatRepository = chatRepository ?? LocalChatRepository(),
|
||||
userRepository = userRepository ?? LocalUserRepository(),
|
||||
pendingMessageRepository =
|
||||
pendingMessageRepository ?? LocalPendingMessageRepository();
|
||||
userRepository = userRepository ?? LocalUserRepository();
|
||||
|
||||
/// The user ID of the person currently looking at the chat
|
||||
final String userId;
|
||||
|
@ -35,9 +28,6 @@ class ChatService {
|
|||
/// The chat repository
|
||||
final ChatRepositoryInterface chatRepository;
|
||||
|
||||
/// The pending messages repository
|
||||
final PendingMessageRepositoryInterface pendingMessageRepository;
|
||||
|
||||
/// The user repository
|
||||
final UserRepositoryInterface userRepository;
|
||||
|
||||
|
@ -145,34 +135,11 @@ class ChatService {
|
|||
/// Returns a list of [MessageModel] stream.
|
||||
Stream<List<MessageModel>?> getMessages({
|
||||
required String chatId,
|
||||
}) {
|
||||
List<MessageModel> mergePendingMessages(
|
||||
List<MessageModel> messages,
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}) =>
|
||||
chatRepository.getMessages(
|
||||
userId: userId,
|
||||
chatId: chatId,
|
||||
);
|
||||
|
||||
/// Signals that new messages should be loaded after the given message.
|
||||
/// The stream should emit the new messages.
|
||||
|
@ -202,73 +169,19 @@ class ChatService {
|
|||
Future<void> sendMessage({
|
||||
required String chatId,
|
||||
required String senderId,
|
||||
String? presetMessageId,
|
||||
required String messageId,
|
||||
String? text,
|
||||
String? messageType,
|
||||
String? imageUrl,
|
||||
Uint8List? imageData,
|
||||
}) 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,
|
||||
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,
|
||||
);
|
||||
}
|
||||
}) =>
|
||||
chatRepository.sendMessage(
|
||||
chatId: chatId,
|
||||
messageId: messageId,
|
||||
text: text,
|
||||
messageType: messageType,
|
||||
senderId: senderId,
|
||||
imageUrl: imageUrl,
|
||||
);
|
||||
|
||||
/// Delete the chat with the given parameters.
|
||||
/// [chatId] is the chat id.
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
name: chat_repository_interface
|
||||
description: "The interface for a chat repository"
|
||||
version: 6.0.0
|
||||
version: 5.0.0
|
||||
homepage: "https://github.com/Iconica-Development"
|
||||
|
||||
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/
|
||||
|
@ -9,7 +9,6 @@ environment:
|
|||
sdk: ">=3.4.3 <4.0.0"
|
||||
|
||||
dependencies:
|
||||
mime: any
|
||||
rxdart: any
|
||||
collection: any
|
||||
|
||||
|
|
|
@ -140,13 +140,6 @@ class FirebaseChatRepository implements ChatRepositoryInterface {
|
|||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getNextMessageId({
|
||||
required String userId,
|
||||
required String chatId,
|
||||
}) async =>
|
||||
"$chatId-$userId-${DateTime.now()}";
|
||||
|
||||
@override
|
||||
Future<void> sendMessage({
|
||||
required String chatId,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
name: firebase_chat_repository
|
||||
description: "Firebase repository implementation for the chat domain repository interface"
|
||||
version: 6.0.0
|
||||
version: 5.0.0
|
||||
homepage: "https://github.com/Iconica-Development"
|
||||
|
||||
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/
|
||||
|
@ -15,7 +15,7 @@ dependencies:
|
|||
|
||||
chat_repository_interface:
|
||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
version: ^6.0.0
|
||||
version: ^5.0.0
|
||||
|
||||
firebase_storage: any
|
||||
cloud_firestore: any
|
||||
|
|
|
@ -19,19 +19,18 @@ class MyApp extends StatelessWidget {
|
|||
useMaterial3: true,
|
||||
),
|
||||
home: const MyHomePage(),
|
||||
// home: FutureBuilder(
|
||||
// future: Firebase.initializeApp(
|
||||
// options: DefaultFirebaseOptions.currentPlatform,
|
||||
// ),
|
||||
// builder: (context, snapshot) {
|
||||
// if (snapshot.connectionState != ConnectionState.done) {
|
||||
// return const Center(
|
||||
// child: CircularProgressIndicator(),
|
||||
// );
|
||||
// }
|
||||
// return const MyHomePage();
|
||||
// },
|
||||
// FutureBuilder(
|
||||
// future: Firebase.initializeApp(
|
||||
// options: DefaultFirebaseOptions.currentPlatform,
|
||||
// ),
|
||||
// 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> {
|
||||
// @override
|
||||
// void initState() {
|
||||
// FirebaseAuth.instance.signInAnonymously();
|
||||
// super.initState();
|
||||
// FirebaseAuth.instance.signInAnonymously();
|
||||
// super.initState();
|
||||
// }
|
||||
|
||||
@override
|
||||
|
|
|
@ -8,7 +8,6 @@ export "package:flutter_chat/src/flutter_chat_navigator_userstories.dart";
|
|||
// Options
|
||||
export "src/config/chat_builders.dart";
|
||||
export "src/config/chat_options.dart";
|
||||
export "src/config/chat_time_indicator_options.dart";
|
||||
export "src/config/chat_translations.dart";
|
||||
export "src/config/screen_types.dart";
|
||||
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
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_chat/flutter_chat.dart";
|
||||
import "package:flutter_chat/src/config/chat_builders.dart";
|
||||
import "package:flutter_chat/src/config/chat_semantics.dart";
|
||||
import "package:flutter_chat/src/config/chat_translations.dart";
|
||||
|
||||
/// The chat options
|
||||
/// Use this class to configure the chat options.
|
||||
|
@ -26,22 +28,14 @@ class ChatOptions {
|
|||
this.onNoChats,
|
||||
this.imageQuality = 20,
|
||||
this.imageProviderResolver = _defaultImageProviderResolver,
|
||||
this.timeIndicatorOptions = const ChatTimeIndicatorOptions(),
|
||||
ChatRepositoryInterface? chatRepository,
|
||||
UserRepositoryInterface? userRepository,
|
||||
PendingMessageRepositoryInterface? pendingMessagesRepository,
|
||||
}) : chatRepository = chatRepository ?? LocalChatRepository(),
|
||||
userRepository = userRepository ?? LocalUserRepository(),
|
||||
pendingMessagesRepository =
|
||||
pendingMessagesRepository ?? LocalPendingMessageRepository();
|
||||
userRepository = userRepository ?? LocalUserRepository();
|
||||
|
||||
/// The implementation for communication with persistance layer for chats
|
||||
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
|
||||
final UserRepositoryInterface userRepository;
|
||||
|
||||
|
@ -115,9 +109,6 @@ class ChatOptions {
|
|||
/// the images in the entire userstory. If not provided, CachedNetworkImage
|
||||
/// will be used.
|
||||
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
|
||||
|
@ -154,7 +145,6 @@ class MessageTheme {
|
|||
this.borderColor,
|
||||
this.textColor,
|
||||
this.timeTextColor,
|
||||
this.imageBackgroundColor,
|
||||
this.borderRadius,
|
||||
this.messageAlignment,
|
||||
this.messageSidePadding,
|
||||
|
@ -171,7 +161,6 @@ class MessageTheme {
|
|||
borderColor: theme.colorScheme.primary,
|
||||
textColor: theme.colorScheme.onPrimary,
|
||||
timeTextColor: theme.colorScheme.onPrimary,
|
||||
imageBackgroundColor: theme.colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
textAlignment: TextAlign.start,
|
||||
messageSidePadding: 144.0,
|
||||
|
@ -210,12 +199,6 @@ class MessageTheme {
|
|||
/// Defaults to [ThemeData.colorScheme.primaryColor]
|
||||
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
|
||||
/// Defaults to [BorderRadius.circular(12)]
|
||||
final BorderRadius? borderRadius;
|
||||
|
@ -246,7 +229,6 @@ class MessageTheme {
|
|||
Color? borderColor,
|
||||
Color? textColor,
|
||||
Color? timeTextColor,
|
||||
Color? imageBackgroundColor,
|
||||
BorderRadius? borderRadius,
|
||||
double? messageSidePadding,
|
||||
TextAlign? messageAlignment,
|
||||
|
@ -261,7 +243,6 @@ class MessageTheme {
|
|||
borderColor: borderColor ?? this.borderColor,
|
||||
textColor: textColor ?? this.textColor,
|
||||
timeTextColor: timeTextColor ?? this.timeTextColor,
|
||||
imageBackgroundColor: imageBackgroundColor ?? this.imageBackgroundColor,
|
||||
borderRadius: borderRadius ?? this.borderRadius,
|
||||
messageSidePadding: messageSidePadding ?? this.messageSidePadding,
|
||||
messageAlignment: messageAlignment ?? this.messageAlignment,
|
||||
|
@ -279,8 +260,6 @@ class MessageTheme {
|
|||
borderColor: borderColor ?? other.borderColor,
|
||||
textColor: textColor ?? other.textColor,
|
||||
timeTextColor: timeTextColor ?? other.timeTextColor,
|
||||
imageBackgroundColor:
|
||||
imageBackgroundColor ?? other.imageBackgroundColor,
|
||||
borderRadius: borderRadius ?? other.borderRadius,
|
||||
messageSidePadding: messageSidePadding ?? other.messageSidePadding,
|
||||
messageAlignment: messageAlignment ?? other.messageAlignment,
|
||||
|
@ -303,10 +282,7 @@ ImageProvider _defaultImageProviderResolver(
|
|||
BuildContext context,
|
||||
Uri image,
|
||||
) =>
|
||||
switch (image.scheme) {
|
||||
"data" => MemoryImage(image.data!.contentAsBytes()),
|
||||
_ => CachedNetworkImageProvider(image.toString()),
|
||||
};
|
||||
CachedNetworkImageProvider(image.toString());
|
||||
|
||||
/// All configurable paddings and whitespaces within the userstory
|
||||
class ChatSpacing {
|
||||
|
|
|
@ -1,105 +0,0 @@
|
|||
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);
|
|
@ -4,8 +4,6 @@
|
|||
|
||||
// ignore_for_file: public_member_api_docs
|
||||
|
||||
import "package:intl/intl.dart";
|
||||
|
||||
/// Class that holds all the translations for the chat component view and
|
||||
/// the corresponding userstory
|
||||
class ChatTranslations {
|
||||
|
@ -52,7 +50,6 @@ class ChatTranslations {
|
|||
required this.groupNameEmpty,
|
||||
required this.messagesLoadingError,
|
||||
required this.next,
|
||||
required this.chatTimeIndicatorLabel,
|
||||
});
|
||||
|
||||
/// Default translations for the chat component view
|
||||
|
@ -98,8 +95,6 @@ class ChatTranslations {
|
|||
this.groupNameEmpty = "Group",
|
||||
this.messagesLoadingError = "Error loading messages, you can reload below:",
|
||||
this.next = "Next",
|
||||
this.chatTimeIndicatorLabel =
|
||||
ChatTranslations.defaultChatTimeIndicatorLabel,
|
||||
});
|
||||
|
||||
final String chatsTitle;
|
||||
|
@ -145,33 +140,6 @@ class ChatTranslations {
|
|||
/// to be loaded.
|
||||
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;
|
||||
|
||||
// copyWith method to override the default values
|
||||
|
@ -214,7 +182,6 @@ class ChatTranslations {
|
|||
String? groupNameEmpty,
|
||||
String? messagesLoadingError,
|
||||
String? next,
|
||||
String Function(int dateOffset, DateTime time)? chatTimeIndicatorLabel,
|
||||
}) =>
|
||||
ChatTranslations(
|
||||
chatsTitle: chatsTitle ?? this.chatsTitle,
|
||||
|
@ -267,7 +234,5 @@ class ChatTranslations {
|
|||
groupNameEmpty: groupNameEmpty ?? this.groupNameEmpty,
|
||||
messagesLoadingError: messagesLoadingError ?? this.messagesLoadingError,
|
||||
next: next ?? this.next,
|
||||
chatTimeIndicatorLabel:
|
||||
chatTimeIndicatorLabel ?? this.chatTimeIndicatorLabel,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -83,7 +83,6 @@ class _FlutterChatEntryWidgetState extends State<FlutterChatEntryWidget> {
|
|||
userId: widget.userId,
|
||||
chatRepository: widget.options?.chatRepository,
|
||||
userRepository: widget.options?.userRepository,
|
||||
pendingMessageRepository: widget.options?.pendingMessagesRepository,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -94,7 +94,6 @@ abstract class _BaseChatNavigatorUserstory extends HookWidget {
|
|||
userId: userId,
|
||||
chatRepository: options.chatRepository,
|
||||
userRepository: options.userRepository,
|
||||
pendingMessageRepository: options.pendingMessagesRepository,
|
||||
),
|
||||
[userId, options],
|
||||
);
|
||||
|
|
|
@ -50,16 +50,27 @@ MaterialPageRoute chatDetailRoute({
|
|||
chatId: chatId,
|
||||
onExit: onExit,
|
||||
onReadChat: (chat) async => chatService.markAsRead(chatId: chat.id),
|
||||
onUploadImage: (data) async => chatService.sendImageMessage(
|
||||
chatId: chatId,
|
||||
userId: userId,
|
||||
data: data,
|
||||
),
|
||||
onMessageSubmit: (text) async => chatService.sendMessage(
|
||||
chatId: chatId,
|
||||
senderId: userId,
|
||||
text: text,
|
||||
),
|
||||
onUploadImage: (data) async {
|
||||
var path = await chatService.uploadImage(
|
||||
path: "chats/$chatId-$userId-${DateTime.now()}",
|
||||
image: data,
|
||||
chatId: chatId,
|
||||
);
|
||||
await chatService.sendMessage(
|
||||
messageId: "$chatId-$userId-${DateTime.now()}",
|
||||
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 {
|
||||
if (chat.isGroupChat) {
|
||||
await _routeToScreen(
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
import "dart:async";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_accessibility/flutter_accessibility.dart";
|
||||
import "package:flutter_chat/flutter_chat.dart";
|
||||
import "package:flutter_chat/src/config/chat_options.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_widgets.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";
|
||||
|
||||
/// Chat detail screen
|
||||
|
@ -462,26 +465,14 @@ class _ChatBody extends HookWidget {
|
|||
bubbleChildren
|
||||
.add(ChatNoMessages(isGroupChat: chat?.isGroupChat ?? false));
|
||||
} else {
|
||||
for (var (index, currentMessage) in messages.indexed) {
|
||||
var previousMessage = index > 0 ? messages[index - 1] : null;
|
||||
|
||||
if (options.timeIndicatorOptions.isMessageInNewTimeSection(
|
||||
context,
|
||||
previousMessage,
|
||||
currentMessage,
|
||||
)) {
|
||||
bubbleChildren.add(
|
||||
ChatTimeIndicator(
|
||||
forDate: currentMessage.timestamp,
|
||||
),
|
||||
);
|
||||
}
|
||||
for (var (index, msg) in messages.indexed) {
|
||||
var prevMsg = index > 0 ? messages[index - 1] : null;
|
||||
|
||||
bubbleChildren.add(
|
||||
ChatBubble(
|
||||
message: currentMessage,
|
||||
previousMessage: previousMessage,
|
||||
sender: userMap[currentMessage.senderId],
|
||||
message: msg,
|
||||
previousMessage: prevMsg,
|
||||
sender: userMap[msg.senderId],
|
||||
onPressSender: onPressUserProfile,
|
||||
semanticIdTitle: options.semantics.chatBubbleTitle(index),
|
||||
semanticIdTime: options.semantics.chatBubbleTime(index),
|
||||
|
@ -497,27 +488,19 @@ class _ChatBody extends HookWidget {
|
|||
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(
|
||||
children: [
|
||||
if (chatIsLoading && options.enableLoadingIndicator) ...[
|
||||
Expanded(
|
||||
child: _CloseKeyboardOnTap(
|
||||
child: options.builders.loadingWidgetBuilder.call(context),
|
||||
),
|
||||
),
|
||||
Expanded(child: options.builders.loadingWidgetBuilder.call(context)),
|
||||
] else ...[
|
||||
Expanded(
|
||||
child: _CloseKeyboardOnTap(
|
||||
child: messageList,
|
||||
child: ListView.builder(
|
||||
reverse: false,
|
||||
controller: scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(top: 24),
|
||||
itemCount: listViewChildren.length,
|
||||
itemBuilder: (context, index) => listViewChildren[index],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -527,26 +510,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.
|
||||
class ErrorLoadingMessages extends StatelessWidget {
|
||||
/// Create default error displaying widget for error in loading messages
|
||||
|
|
|
@ -63,37 +63,34 @@ class ChatBottomInputSection extends HookWidget {
|
|||
}
|
||||
|
||||
/// Image and send buttons
|
||||
var messageSendButtons = Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CustomSemantics(
|
||||
identifier: options.semantics.chatSelectImageIconButton,
|
||||
child: IconButton(
|
||||
alignment: Alignment.bottomRight,
|
||||
onPressed: isLoading ? null : onPressSelectImage,
|
||||
icon: Icon(
|
||||
Icons.image_outlined,
|
||||
color: options.iconEnabledColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
CustomSemantics(
|
||||
identifier: options.semantics.chatSendMessageIconButton,
|
||||
child: IconButton(
|
||||
alignment: Alignment.bottomRight,
|
||||
disabledColor: options.iconDisabledColor,
|
||||
var messageSendButtons = SizedBox(
|
||||
height: 45,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CustomSemantics(
|
||||
identifier: options.semantics.chatSelectImageIconButton,
|
||||
child: IconButton(
|
||||
alignment: Alignment.bottomRight,
|
||||
onPressed: isLoading ? null : onPressSelectImage,
|
||||
icon: Icon(
|
||||
Icons.image_outlined,
|
||||
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
|
||||
// textfield
|
||||
suffixIcon: ExcludeFocus(
|
||||
child: AbsorbPointer(
|
||||
child: Opacity(
|
||||
opacity: 0.0,
|
||||
child: messageSendButtons,
|
||||
),
|
||||
suffixIcon: AbsorbPointer(
|
||||
child: Opacity(
|
||||
opacity: 0.0,
|
||||
child: messageSendButtons,
|
||||
),
|
||||
),
|
||||
hintText: options.translations.messagePlaceholder,
|
||||
|
|
|
@ -3,7 +3,6 @@ import "package:flutter/material.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/util/scope.dart";
|
||||
import "package:flutter_chat/src/util/utils.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
|
||||
/// Widget displayed when there are no messages in the chat.
|
||||
|
@ -103,32 +102,3 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,43 +0,0 @@
|
|||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,3 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:chat_repository_interface/chat_repository_interface.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_accessibility/flutter_accessibility.dart";
|
||||
|
@ -99,17 +97,10 @@ class DefaultChatMessageBuilder extends StatelessWidget {
|
|||
var isSameSender = previousMessage != null &&
|
||||
previousMessage?.senderId == message.senderId;
|
||||
|
||||
var hasPreviousIndicator = options.timeIndicatorOptions.sectionCheck(
|
||||
context,
|
||||
previousMessage,
|
||||
message,
|
||||
);
|
||||
|
||||
var isMessageFromSelf = message.senderId == userId;
|
||||
|
||||
var chatMessage = _ChatMessageBubble(
|
||||
isSameSender: isSameSender,
|
||||
hasPreviousIndicator: hasPreviousIndicator,
|
||||
isMessageFromSelf: isMessageFromSelf,
|
||||
previousMessage: previousMessage,
|
||||
message: message,
|
||||
|
@ -149,34 +140,9 @@ 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 {
|
||||
const _ChatMessageBubble({
|
||||
required this.isSameSender,
|
||||
required this.hasPreviousIndicator,
|
||||
required this.isMessageFromSelf,
|
||||
required this.message,
|
||||
required this.previousMessage,
|
||||
|
@ -188,7 +154,6 @@ class _ChatMessageBubble extends StatelessWidget {
|
|||
});
|
||||
|
||||
final bool isSameSender;
|
||||
final bool hasPreviousIndicator;
|
||||
final bool isMessageFromSelf;
|
||||
final MessageModel message;
|
||||
final MessageModel? previousMessage;
|
||||
|
@ -226,15 +191,12 @@ class _ChatMessageBubble extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
|
||||
var messageTimeRow = Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 8.0,
|
||||
right: 8.0,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
CustomSemantics(
|
||||
var messageTimeRow = Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8, bottom: 4),
|
||||
child: CustomSemantics(
|
||||
identifier: semanticIdTime,
|
||||
value: messageTime,
|
||||
child: Text(
|
||||
|
@ -245,28 +207,18 @@ class _ChatMessageBubble extends StatelessWidget {
|
|||
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(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (isNewSection) ...[
|
||||
if (messageTheme.showName ?? !isSameSender) ...[
|
||||
SizedBox(height: options.spacing.chatBetweenMessagesPadding),
|
||||
senderTitleText,
|
||||
],
|
||||
if (showName) senderTitleText,
|
||||
const SizedBox(height: 4),
|
||||
DefaultChatMessageContainer(
|
||||
backgroundColor: messageTheme.backgroundColor!,
|
||||
|
@ -280,7 +232,6 @@ class _ChatMessageBubble extends StatelessWidget {
|
|||
_DefaultChatImage(
|
||||
message: message,
|
||||
messageTheme: messageTheme,
|
||||
options: options,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
|
@ -317,121 +268,40 @@ class _ChatMessageBubble extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class _DefaultChatImage extends StatefulWidget {
|
||||
class _DefaultChatImage extends StatelessWidget {
|
||||
const _DefaultChatImage({
|
||||
required this.message,
|
||||
required this.messageTheme,
|
||||
required this.options,
|
||||
});
|
||||
|
||||
final MessageModel message;
|
||||
final ChatOptions options;
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
var theme = Theme.of(context);
|
||||
|
||||
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,
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
var chatScope = ChatScope.of(context);
|
||||
var options = chatScope.options;
|
||||
var textTheme = Theme.of(context).textTheme;
|
||||
var imageUrl = message.imageUrl!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => ConstrainedBox(
|
||||
constraints: BoxConstraints.tightForFinite(
|
||||
width: constraints.maxWidth,
|
||||
height: constraints.maxWidth,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: ColoredBox(
|
||||
color: widget.messageTheme.imageBackgroundColor ??
|
||||
theme.colorScheme.secondaryContainer,
|
||||
child: asyncImageBuilder,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Image(
|
||||
image:
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -439,30 +309,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
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import "dart:io";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_accessibility/flutter_accessibility.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_image_picker/flutter_image_picker.dart";
|
||||
|
||||
|
@ -16,7 +16,15 @@ Future<void> onPressSelectImage(
|
|||
var image = await options.builders.imagePickerBuilder.call(context);
|
||||
|
||||
if (image == null) return;
|
||||
if (!context.mounted) return;
|
||||
var messenger = ScaffoldMessenger.of(context)
|
||||
..showSnackBar(
|
||||
_getImageLoadingSnackbar(context, options.translations),
|
||||
)
|
||||
..activate();
|
||||
await onUploadImage(image);
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
messenger.hideCurrentSnackBar();
|
||||
}
|
||||
|
||||
/// Default image picker dialog for selecting an image from the gallery or
|
||||
|
@ -53,7 +61,6 @@ class DefaultImagePickerDialog extends StatelessWidget {
|
|||
child: ImagePicker(
|
||||
config: ImagePickerConfig(
|
||||
imageQuality: options.imageQuality.clamp(0, 100),
|
||||
cameraOption: !kIsWeb && (Platform.isAndroid || Platform.isIOS),
|
||||
),
|
||||
theme: ImagePickerTheme(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,18 +1 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
name: flutter_chat
|
||||
description: "User story of the chat domain for quick integration into flutter apps"
|
||||
version: 6.0.0
|
||||
version: 5.0.0
|
||||
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/
|
||||
|
||||
environment:
|
||||
|
@ -26,7 +26,7 @@ dependencies:
|
|||
version: ^1.6.0
|
||||
chat_repository_interface:
|
||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
version: ^6.0.0
|
||||
version: ^5.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
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));
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Reference in a new issue