feat: add chat detailscreen styling options for beep

This commit is contained in:
Freek van de Ven 2025-02-03 08:06:35 +01:00
parent 24695aa87a
commit dd0f988efd
12 changed files with 1224 additions and 688 deletions

View file

@ -1,7 +1,10 @@
## 5.0.0 - WIP
- Removed the default values for the ChatOptions that are now nullable so they resolve to the ThemeData values
- Added chatAlignment to change the alignment of the chat messages
- Added messageType to the ChatMessageModel to allow for different type of messages, it is nullable to remain backwards compatible
- Get the color for the imagepicker from the Theme's primaryColor
- Added chatMessageBuilder to the userstory configuration to customize the chat messages
- Update the default chat message builder to a new design
- Added ChatScope that can be used to get the ChatService and ChatTranslations from the context. If you use individual components instead of the userstory you need to wrap them with the ChatScope. The options and service will be removed from all the component constructors.
## 4.0.0

View file

@ -5,9 +5,11 @@
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related

View file

@ -11,14 +11,19 @@ export "src/config/chat_options.dart";
export "src/config/chat_translations.dart";
export "src/config/screen_types.dart";
// Screens
export "src/screens/chat_detail_screen.dart";
// Screens and widgets
export "src/screens/chat_detail/chat_detail_screen.dart";
export "src/screens/chat_detail/widgets/default_message_builder.dart";
export "src/screens/chat_detail/widgets/old_message_builder.dart";
export "src/screens/chat_profile_screen.dart";
export "src/screens/chat_screen.dart";
export "src/screens/creation/new_chat_screen.dart";
export "src/screens/creation/new_group_chat_overview.dart";
export "src/screens/creation/new_group_chat_screen.dart";
// Services
export "src/services/date_formatter.dart";
export "src/services/pop_handler.dart";
// Utils
export "src/util/scope.dart";

View file

@ -2,6 +2,8 @@ import "package:chat_repository_interface/chat_repository_interface.dart";
import "package:flutter/material.dart";
import "package:flutter_chat/src/config/chat_translations.dart";
import "package:flutter_chat/src/config/screen_types.dart";
import "package:flutter_chat/src/screens/chat_detail/widgets/default_loader.dart";
import "package:flutter_chat/src/screens/chat_detail/widgets/default_message_builder.dart";
/// The chat builders
class ChatBuilders {
@ -17,9 +19,9 @@ class ChatBuilders {
this.newChatButtonBuilder,
this.noUsersPlaceholderBuilder,
this.chatTitleBuilder,
this.chatMessageBuilder,
this.chatMessageBuilder = DefaultChatMessageBuilder.builder,
this.usernameBuilder,
this.loadingWidgetBuilder,
this.loadingWidgetBuilder = DefaultChatLoadingOverlay.builder,
});
/// The base screen builder
@ -64,7 +66,7 @@ class ChatBuilders {
final Widget Function(String chatTitle)? chatTitleBuilder;
/// The chat message builder
final ChatMessageBuilder? chatMessageBuilder;
final ChatMessageBuilder chatMessageBuilder;
/// The username builder
final Widget Function(String userFullName)? usernameBuilder;
@ -73,7 +75,7 @@ class ChatBuilders {
final ImagePickerContainerBuilder? imagePickerContainerBuilder;
/// The loading widget builder
final Widget? Function(BuildContext context)? loadingWidgetBuilder;
final Widget? Function(BuildContext context) loadingWidgetBuilder;
}
/// The button builder
@ -121,6 +123,8 @@ typedef ChatMessageBuilder = Widget? Function(
BuildContext context,
MessageModel message,
MessageModel? previousMessage,
UserModel user,
Function(UserModel user) onPressUserProfile,
);
/// The group avatar builder

View file

@ -1,5 +1,5 @@
import "dart:ui";
import "package:chat_repository_interface/chat_repository_interface.dart";
import "package:flutter/material.dart";
import "package:flutter_chat/src/config/chat_builders.dart";
import "package:flutter_chat/src/config/chat_translations.dart";
@ -10,11 +10,15 @@ class ChatOptions {
const ChatOptions({
this.dateformat,
this.groupChatEnabled = true,
this.showTimes = true,
this.enableLoadingIndicator = false,
this.translations = const ChatTranslations.empty(),
this.builders = const ChatBuilders(),
this.iconEnabledColor = const Color(0xFF212121),
this.iconDisabledColor = const Color(0xFF9E9E9E),
this.spacing = const ChatSpacing(),
this.messageTheme,
this.messageThemeResolver = _defaultMessageThemeResolver,
this.iconEnabledColor,
this.iconDisabledColor,
this.chatAlignment,
this.onNoChats,
this.pageSize = 20,
});
@ -29,17 +33,37 @@ class ChatOptions {
/// [builders] is the chat builders.
final ChatBuilders builders;
//// The spacing between elements of the chat
final ChatSpacing spacing;
/// [groupChatEnabled] is a boolean that indicates if group chat is enabled.
final bool groupChatEnabled;
/// [showTimes] is a boolean that indicates if the chat times are shown.
final bool showTimes;
/// [iconEnabledColor] is the color of the enabled icon.
final Color iconEnabledColor;
/// Defaults to the [IconThemeData.color] of the current [Theme]
final Color? iconEnabledColor;
/// [iconDisabledColor] is the color of the disabled icon.
final Color iconDisabledColor;
/// Defaults to the [ThemeData.disabledColor] of the current [Theme]
final Color? iconDisabledColor;
/// The default [MessageTheme] for the chat messages.
/// If not set, the default values are based on the current [Theme].
final MessageTheme? messageTheme;
/// If [messageThemeResolver] is set and returns null for a message,
/// the [messageTheme] will be used.
final MessageThemeResolver messageThemeResolver;
/// The alignment of the chatmessages in the ChatDetailScreen.
/// Defaults to [Alignment.bottomCenter]
final Alignment? chatAlignment;
/// Enable the loading indicator that is over the entire chat screen while
/// loading messages. Defaults to false. The streambuilder for chat messages
/// already shows a loading indicator. So this is an additional loading that
/// can be used for more customization.
final bool enableLoadingIndicator;
/// [onNoChats] is a function that is triggered when there are no chats.
final Function? onNoChats;
@ -47,3 +71,148 @@ class ChatOptions {
/// [pageSize] is the number of chats to load at a time.
final int pageSize;
}
/// Typedef for the messageThemeResolver function that is used to get a
/// [MessageTheme] for a message. This can return null so you can fall back to
/// default values for some messages.
typedef MessageThemeResolver = MessageTheme? Function(
BuildContext context,
MessageModel message,
UserModel? sender,
);
/// The message theme
class MessageTheme {
/// The message theme constructor
const MessageTheme({
this.backgroundColor,
this.nameColor,
this.borderColor,
this.textColor,
this.timeTextColor,
this.messageAlignment,
this.messageSidePadding,
this.textAlignment,
this.showName,
this.showTime,
});
///
factory MessageTheme.fromTheme(ThemeData theme) => MessageTheme(
backgroundColor: theme.colorScheme.primary,
nameColor: theme.colorScheme.onPrimary,
borderColor: theme.colorScheme.primary,
textColor: theme.colorScheme.onPrimary,
timeTextColor: theme.colorScheme.onPrimary,
textAlignment: TextAlign.start,
messageSidePadding: 144.0,
messageAlignment: null,
showName: true,
showTime: true,
);
/// The alignment of the message in the chat
/// By default, the current user is aligned to the right and the other senders
/// are aligned to the left.
final TextAlign? messageAlignment;
/// The alignment of the text in the message
/// Defaults to [TextAlign.start]
final TextAlign? textAlignment;
/// The color of the message text
/// Defaults to [ThemeData.colorScheme.onPrimary]
final Color? textColor;
/// The color of the text displaying the time
/// Defaults to [ThemeData.colorScheme.onPrimary]
final Color? timeTextColor;
/// The color of the sender name
/// Defaults to [ThemeData.colorScheme.onPrimary]
final Color? nameColor;
/// The color of the message container background
/// Defaults to [ThemeData.colorScheme.primary]
final Color? backgroundColor;
/// The color of the border around the message
/// Defaults to [ThemeData.colorScheme.primaryColor]
final Color? borderColor;
/// The padding on the side of the message
/// If not set, the padding is 144.0
final double? messageSidePadding;
/// If the name of the sender should be shown above the message
/// Defaults to true
final bool? showName;
/// If the time of the message should be shown below the message
/// Defaults to true
final bool? showTime;
/// Creates a copy of the current object with the provided values
MessageTheme copyWith({
Color? backgroundColor,
Color? nameColor,
Color? borderColor,
Color? textColor,
Color? timeTextColor,
double? messageSidePadding,
TextAlign? messageAlignment,
TextAlign? textAlignment,
bool? showName,
bool? showTime,
}) =>
MessageTheme(
backgroundColor: backgroundColor ?? this.backgroundColor,
nameColor: nameColor ?? this.nameColor,
borderColor: borderColor ?? this.borderColor,
textColor: textColor ?? this.textColor,
timeTextColor: timeTextColor ?? this.timeTextColor,
messageSidePadding: messageSidePadding ?? this.messageSidePadding,
messageAlignment: messageAlignment ?? this.messageAlignment,
textAlignment: textAlignment ?? this.textAlignment,
showName: showName ?? this.showName,
showTime: showTime ?? this.showTime,
);
/// If a value is null in the first object, the value from the second object
/// is used.
MessageTheme operator |(MessageTheme other) => MessageTheme(
backgroundColor: backgroundColor ?? other.backgroundColor,
nameColor: nameColor ?? other.nameColor,
borderColor: borderColor ?? other.borderColor,
textColor: textColor ?? other.textColor,
timeTextColor: timeTextColor ?? other.timeTextColor,
messageSidePadding: messageSidePadding ?? other.messageSidePadding,
messageAlignment: messageAlignment ?? other.messageAlignment,
textAlignment: textAlignment ?? other.textAlignment,
showName: showName ?? other.showName,
showTime: showTime ?? other.showTime,
);
}
MessageTheme? _defaultMessageThemeResolver(
BuildContext context,
MessageModel message,
UserModel? sender,
) =>
null;
/// All configurable paddings and whitespaces within the userstory
class ChatSpacing {
/// Creates a ChatSpacing object
const ChatSpacing({
this.chatBetweenMessagesPadding = 16.0,
this.chatSidePadding = 20.0,
});
/// The padding between the chat messages and the screen edge
final double chatSidePadding;
/// The padding between different chat messages if they are not from the same
/// sender.
final double chatBetweenMessagesPadding;
}

View file

@ -1,5 +1,5 @@
import "package:flutter/material.dart";
import "package:flutter_chat/src/screens/chat_detail_screen.dart";
import "package:flutter_chat/src/screens/chat_detail/chat_detail_screen.dart";
import "package:flutter_chat/src/screens/chat_profile_screen.dart";
import "package:flutter_chat/src/screens/chat_screen.dart";
import "package:flutter_chat/src/screens/creation/new_chat_screen.dart";

View file

@ -7,7 +7,7 @@ import "dart:async";
import "package:chat_repository_interface/chat_repository_interface.dart";
import "package:flutter/material.dart";
import "package:flutter_chat/src/config/chat_options.dart";
import "package:flutter_chat/src/screens/chat_detail_screen.dart";
import "package:flutter_chat/src/screens/chat_detail/chat_detail_screen.dart";
import "package:flutter_chat/src/screens/chat_profile_screen.dart";
import "package:flutter_chat/src/screens/chat_screen.dart";
import "package:flutter_chat/src/screens/creation/new_chat_screen.dart";
@ -114,9 +114,6 @@ class _NavigatorWrapper extends StatelessWidget {
Widget chatDetailScreen(BuildContext context, ChatModel chat) =>
ChatDetailScreen(
userId: userId,
chatService: chatService,
chatOptions: chatOptions,
chat: chat,
onReadChat: (chat) async =>
chatService.markAsRead(chatId: chat.id, userId: userId),

View file

@ -0,0 +1,515 @@
import "dart:async";
import "dart:typed_data";
import "package:chat_repository_interface/chat_repository_interface.dart";
import "package:flutter/material.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/default_message_builder.dart";
import "package:flutter_chat/src/screens/creation/widgets/image_picker.dart";
import "package:flutter_chat/src/util/scope.dart";
/// Chat detail screen
/// Seen when a user clicks on a chat
class ChatDetailScreen extends StatefulWidget {
/// Constructs a [ChatDetailScreen].
const ChatDetailScreen({
required this.chat,
required this.onPressChatTitle,
required this.onPressUserProfile,
required this.onUploadImage,
required this.onMessageSubmit,
required this.onReadChat,
this.getChatTitle,
super.key,
});
/// The chat model currently being viewed
final ChatModel chat;
/// Callback function triggered when the chat title is pressed.
final Function(ChatModel) onPressChatTitle;
/// Callback function triggered when the user profile is pressed.
final Function(UserModel) onPressUserProfile;
/// Callback function triggered when an image is uploaded.
final Function(Uint8List image) onUploadImage;
/// Callback function triggered when a message is submitted.
final Function(String text) onMessageSubmit;
/// Callback function triggered when the chat is read.
final Function(ChatModel chat) onReadChat;
/// Callback function to get the chat title
final String Function(ChatModel chat)? getChatTitle;
@override
State<ChatDetailScreen> createState() => _ChatDetailScreenState();
}
class _ChatDetailScreenState extends State<ChatDetailScreen> {
String? chatTitle;
@override
void initState() {
super.initState();
if (widget.chat.isGroupChat) {
chatTitle = widget.chat.chatName;
}
if (chatTitle != null) return;
WidgetsBinding.instance.addPostFrameCallback((_) async {
var chatScope = ChatScope.of(context);
if (widget.chat.isGroupChat) {
chatTitle = chatScope.options.translations.groupNameEmpty;
} else {
await _getTitle(chatScope);
}
});
}
Future<void> _getTitle(ChatScope chatScope) async {
if (widget.getChatTitle != null) {
chatTitle = widget.getChatTitle!.call(widget.chat);
} else {
var userId = widget.chat.users
.firstWhere((element) => element != chatScope.userId);
var user = await chatScope.service.getUser(userId: userId).first;
chatTitle = user.fullname ?? chatScope.options.translations.anonymousUser;
}
setState(() {});
}
@override
Widget build(BuildContext context) {
var chatOptions = ChatScope.of(context).options;
var appBar = _AppBar(
chatTitle: chatTitle,
onPressChatTitle: widget.onPressChatTitle,
chatModel: widget.chat,
);
var body = _Body(
chat: widget.chat,
onPressUserProfile: widget.onPressUserProfile,
onUploadImage: widget.onUploadImage,
onMessageSubmit: widget.onMessageSubmit,
onReadChat: widget.onReadChat,
);
if (chatOptions.builders.baseScreenBuilder == null) {
return Scaffold(
appBar: appBar,
body: body,
);
}
return chatOptions.builders.baseScreenBuilder!.call(
context,
widget.mapScreenType,
appBar,
body,
);
}
}
class _AppBar extends StatelessWidget implements PreferredSizeWidget {
const _AppBar({
required this.chatTitle,
required this.onPressChatTitle,
required this.chatModel,
});
final String? chatTitle;
final Function(ChatModel) onPressChatTitle;
final ChatModel chatModel;
@override
Widget build(BuildContext context) {
var options = ChatScope.of(context).options;
var theme = Theme.of(context);
return AppBar(
iconTheme: theme.appBarTheme.iconTheme ??
const IconThemeData(color: Colors.white),
centerTitle: true,
leading: GestureDetector(
onTap: () {
Navigator.popUntil(context, (route) => route.isFirst);
},
child: const Icon(
Icons.arrow_back_ios,
),
),
title: GestureDetector(
onTap: () => onPressChatTitle.call(chatModel),
child: options.builders.chatTitleBuilder?.call(chatTitle ?? "") ??
Text(
chatTitle ?? "",
overflow: TextOverflow.ellipsis,
),
),
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
class _Body extends StatefulWidget {
const _Body({
required this.chat,
required this.onPressUserProfile,
required this.onUploadImage,
required this.onMessageSubmit,
required this.onReadChat,
});
final ChatModel chat;
final Function(UserModel) onPressUserProfile;
final Function(Uint8List image) onUploadImage;
final Function(String message) onMessageSubmit;
final Function(ChatModel chat) onReadChat;
@override
State<_Body> createState() => _BodyState();
}
class _BodyState extends State<_Body> {
final ScrollController controller = ScrollController();
bool showIndicator = false;
late int pageSize = 20;
int page = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
var chatScope = ChatScope.of(context);
pageSize = chatScope.options.pageSize;
});
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
var chatScope = ChatScope.of(context);
var options = chatScope.options;
var service = chatScope.service;
var userId = chatScope.userId;
void handleScroll(PointerMoveEvent event) {
if (!showIndicator &&
controller.offset >= controller.position.maxScrollExtent &&
!controller.position.outOfRange) {
setState(() {
showIndicator = true;
page++;
});
Future.delayed(const Duration(seconds: 2), () {
if (!mounted) return;
setState(() {
showIndicator = false;
});
});
}
}
return Stack(
children: [
Column(
children: [
Expanded(
child: Align(
alignment: options.chatAlignment ?? Alignment.bottomCenter,
child: StreamBuilder<List<MessageModel>?>(
stream: service.getMessages(
userId: userId,
chatId: widget.chat.id,
pageSize: pageSize,
page: page,
),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
var messages = snapshot.data?.reversed.toList() ?? [];
WidgetsBinding.instance.addPostFrameCallback((_) async {
await widget.onReadChat(widget.chat);
});
return Listener(
onPointerMove: handleScroll,
child: ListView(
shrinkWrap: true,
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
reverse: messages.isNotEmpty,
padding: const EdgeInsets.only(top: 24.0),
children: [
if (messages.isEmpty && !showIndicator) ...[
_ChatNoMessages(widget: widget),
],
for (var (index, message) in messages.indexed) ...[
if (widget.chat.id == message.chatId) ...[
_ChatBubble(
key: ValueKey(message.id),
message: message,
previousMessage: index < messages.length - 1
? messages[index + 1]
: null,
onPressUserProfile: widget.onPressUserProfile,
),
],
],
],
),
);
},
),
),
),
_ChatBottom(
chat: widget.chat,
onPressSelectImage: () async => onPressSelectImage.call(
context,
options,
widget.onUploadImage,
),
onMessageSubmit: widget.onMessageSubmit,
options: options,
),
],
),
if (showIndicator && options.enableLoadingIndicator) ...[
options.builders.loadingWidgetBuilder.call(context) ??
const SizedBox.shrink(),
],
],
);
}
}
class _ChatNoMessages extends StatelessWidget {
const _ChatNoMessages({
required this.widget,
});
final _Body widget;
@override
Widget build(BuildContext context) {
var chatScope = ChatScope.of(context);
var options = chatScope.options;
var translations = options.translations;
var theme = Theme.of(context);
return Center(
child: Text(
widget.chat.isGroupChat
? translations.writeFirstMessageInGroupChat
: translations.writeMessageToStartChat,
style: theme.textTheme.bodySmall,
),
);
}
}
class _ChatBottom extends StatefulWidget {
const _ChatBottom({
required this.chat,
required this.onMessageSubmit,
required this.options,
this.onPressSelectImage,
});
/// Callback function invoked when a message is submitted.
final Function(String text) onMessageSubmit;
/// Callback function invoked when the select image button is pressed.
final VoidCallback? onPressSelectImage;
/// The chat model.
final ChatModel chat;
final ChatOptions options;
@override
State<_ChatBottom> createState() => _ChatBottomState();
}
class _ChatBottomState extends State<_ChatBottom> {
final TextEditingController _textEditingController = TextEditingController();
bool _isTyping = false;
bool _isSending = false;
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
_textEditingController.addListener(() {
setState(() {
_isTyping = _textEditingController.text.isNotEmpty;
});
});
Future<void> sendMessage() async {
setState(() {
_isSending = true;
});
var value = _textEditingController.text;
if (value.isNotEmpty) {
await widget.onMessageSubmit(value);
_textEditingController.clear();
}
setState(() {
_isSending = false;
});
}
Future<void> Function()? onClickSendMessage;
if (_isTyping && !_isSending) {
onClickSendMessage = () async => sendMessage();
}
var messageSendButtons = Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: widget.onPressSelectImage,
icon: Icon(
Icons.image_outlined,
color: widget.options.iconEnabledColor,
),
),
IconButton(
disabledColor: widget.options.iconDisabledColor,
color: widget.options.iconEnabledColor,
onPressed: onClickSendMessage,
icon: const Icon(
Icons.send_rounded,
),
),
],
);
var defaultInputField = TextField(
textAlign: TextAlign.start,
textAlignVertical: TextAlignVertical.center,
style: theme.textTheme.bodySmall,
textCapitalization: TextCapitalization.sentences,
controller: _textEditingController,
decoration: InputDecoration(
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: const BorderSide(
color: Colors.black,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: const BorderSide(
color: Colors.black,
),
),
contentPadding: const EdgeInsets.symmetric(
vertical: 0,
horizontal: 30,
),
hintText: widget.options.translations.messagePlaceholder,
hintStyle: theme.textTheme.bodyMedium,
fillColor: Colors.white,
filled: true,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(25),
),
borderSide: BorderSide.none,
),
suffixIcon: messageSendButtons,
),
);
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 16,
),
child: SizedBox(
height: 45,
child: widget.options.builders.messageInputBuilder?.call(
context,
_textEditingController,
messageSendButtons,
widget.options.translations,
) ??
defaultInputField,
),
);
}
}
class _ChatBubble extends StatefulWidget {
const _ChatBubble({
required this.message,
required this.onPressUserProfile,
this.previousMessage,
super.key,
});
final MessageModel message;
final MessageModel? previousMessage;
final Function(UserModel user) onPressUserProfile;
@override
State<_ChatBubble> createState() => _ChatBubbleState();
}
class _ChatBubbleState extends State<_ChatBubble> {
@override
Widget build(BuildContext context) {
var chatScope = ChatScope.of(context);
var options = chatScope.options;
var service = chatScope.service;
return StreamBuilder<UserModel>(
stream: service.getUser(userId: widget.message.senderId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
var user = snapshot.data!;
return options.builders.chatMessageBuilder.call(
context,
widget.message,
widget.previousMessage,
user,
widget.onPressUserProfile,
) ??
DefaultChatMessageBuilder(
message: widget.message,
previousMessage: widget.previousMessage,
user: user,
onPressUserProfile: widget.onPressUserProfile,
);
},
);
}
}

View file

@ -0,0 +1,21 @@
import "package:flutter/material.dart";
/// Default chat loading overlay
/// This is displayed over the chat when loading
class DefaultChatLoadingOverlay extends StatelessWidget {
/// Creates a new default chat loading overlay
const DefaultChatLoadingOverlay({super.key});
/// Builds the default chat loading overlay
static Widget builder(BuildContext context) =>
const DefaultChatLoadingOverlay();
@override
Widget build(BuildContext context) => const Column(
children: [
SizedBox(height: 12),
Center(child: CircularProgressIndicator()),
SizedBox(height: 12),
],
);
}

View file

@ -0,0 +1,286 @@
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/src/config/chat_options.dart";
import "package:flutter_chat/src/services/date_formatter.dart";
import "package:flutter_chat/src/util/scope.dart";
/// The default chat message builder that shows messages aligned to the left or
/// right depending on the sender.
/// It can be styled using the [MessageTheme] from the [ChatOptions].
class DefaultChatMessageBuilder extends StatelessWidget {
/// Creates a new [DefaultChatMessageBuilder]
const DefaultChatMessageBuilder({
required this.message,
required this.previousMessage,
required this.user,
required this.onPressUserProfile,
super.key,
});
/// The message that is being built
final MessageModel message;
/// The previous message if any, this can be used to determine if the message
/// is from the same sender as the previous message.
final MessageModel? previousMessage;
/// The user that sent the message
final UserModel user;
/// The function that is called when the user profile is pressed
final Function(UserModel user) onPressUserProfile;
/// implements [ChatMessageBuilder]
static Widget builder(
BuildContext context,
MessageModel message,
MessageModel? previousMessage,
UserModel user,
Function(UserModel user) onPressUserProfile,
) =>
DefaultChatMessageBuilder(
message: message,
previousMessage: previousMessage,
user: user,
onPressUserProfile: onPressUserProfile,
);
/// Merges the [MessageTheme] from the themeresolver with the [MessageTheme]
/// from the options and the [MessageTheme] from the theme. Priority is given
/// to the [MessageTheme] from the themeresolver.
MessageTheme _resolveMessageTheme({
required BuildContext context,
required ChatOptions options,
required MessageModel message,
required UserModel user,
}) =>
[
options.messageThemeResolver(context, message, user),
options.messageTheme,
MessageTheme.fromTheme(Theme.of(context)),
].whereType<MessageTheme>().reduce((value, element) => value | element);
@override
Widget build(BuildContext context) {
var chatScope = ChatScope.of(context);
var options = chatScope.options;
var userId = chatScope.userId;
var messageTheme = _resolveMessageTheme(
context: context,
options: options,
message: message,
user: user,
);
var isSameSender = previousMessage != null &&
previousMessage?.senderId == message.senderId;
var isMessageFromSelf = message.senderId == userId;
var chatMessage = _ChatMessageBubble(
isSameSender: isSameSender,
isMessageFromSelf: isMessageFromSelf,
message: message,
messageTheme: messageTheme,
user: user,
);
var messagePadding = messageTheme.messageSidePadding!;
var standardAlignmentIfNull =
isMessageFromSelf ? TextAlign.right : TextAlign.left;
var leftPaddingMessage =
switch (messageTheme.messageAlignment ?? standardAlignmentIfNull) {
TextAlign.left => 0.0,
TextAlign.right => messagePadding,
_ => messagePadding / 2,
};
var rightPadding =
switch (messageTheme.messageAlignment ?? standardAlignmentIfNull) {
TextAlign.left => messagePadding,
TextAlign.right => 0.0,
_ => messagePadding / 2,
};
return Row(
children: [
SizedBox(width: leftPaddingMessage + options.spacing.chatSidePadding),
chatMessage,
SizedBox(width: rightPadding + options.spacing.chatSidePadding),
],
);
}
}
class _ChatMessageBubble extends StatelessWidget {
const _ChatMessageBubble({
required this.isSameSender,
required this.isMessageFromSelf,
required this.message,
required this.messageTheme,
required this.user,
});
final bool isSameSender;
final bool isMessageFromSelf;
final MessageModel message;
final MessageTheme messageTheme;
final UserModel user;
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
var textTheme = theme.textTheme;
var options = ChatScope.of(context).options;
var dateFormatter = DateFormatter(options: options);
var messageTime = dateFormatter.format(date: message.timestamp);
var senderTitle = Text(
user.firstName ?? "",
style: theme.textTheme.titleMedium,
);
var messageTimeRow = Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Padding(
padding: const EdgeInsets.only(right: 8, bottom: 4),
child: Text(
messageTime,
style: textTheme.bodySmall?.copyWith(
color: messageTheme.textColor,
),
textAlign: TextAlign.end,
),
),
],
);
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (messageTheme.showName! && !isSameSender) ...[
SizedBox(height: options.spacing.chatBetweenMessagesPadding),
senderTitle,
],
const SizedBox(height: 4),
DefaultChatMessageContainer(
backgroundColor: messageTheme.backgroundColor!,
borderColor: messageTheme.borderColor!,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 4),
if (message.imageUrl?.isNotEmpty ?? false) ...[
_DefaultChatImage(
message: message,
messageTheme: messageTheme,
),
const SizedBox(height: 2),
],
if (message.text?.isNotEmpty ?? false) ...[
Padding(
padding: const EdgeInsets.only(
top: 8,
left: 12,
right: 12,
bottom: 4,
),
child: Text(
message.text!,
style: textTheme.bodyLarge?.copyWith(
color: messageTheme.textColor,
),
textAlign: messageTheme.textAlignment,
),
),
],
if (messageTheme.showTime!) ...[
messageTimeRow,
],
],
),
),
],
),
);
}
}
class _DefaultChatImage extends StatelessWidget {
const _DefaultChatImage({
required this.message,
required this.messageTheme,
});
final MessageModel message;
final MessageTheme messageTheme;
@override
Widget build(BuildContext context) {
var textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: SizedBox(
width: double.infinity,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: CachedNetworkImage(
imageUrl: message.imageUrl!,
fit: BoxFit.fitWidth,
errorWidget: (context, url, error) => Text(
"Something went wrong",
style: textTheme.bodyLarge?.copyWith(
color: messageTheme.textColor,
),
),
),
),
),
),
);
}
}
/// A container for the chat message that provides a decoration around the
/// message
class DefaultChatMessageContainer extends StatelessWidget {
/// Creates a new [DefaultChatMessageContainer]
const DefaultChatMessageContainer({
required this.backgroundColor,
required this.borderColor,
required this.child,
super.key,
});
/// The color of the message background
final Color backgroundColor;
/// The color of the border around the message
final Color borderColor;
/// The content of the message
final Widget child;
@override
Widget build(BuildContext context) => DecoratedBox(
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
width: 1,
color: borderColor,
),
),
child: child,
);
}

View file

@ -0,0 +1,201 @@
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/src/services/date_formatter.dart";
import "package:flutter_chat/src/util/scope.dart";
import "package:flutter_profile/flutter_profile.dart";
/// The old chat message builder that shows messages with the user image on the
/// left and the message on the right.
class OldChatMessageBuilder extends StatelessWidget {
/// Creates a new [OldChatMessageBuilder]
const OldChatMessageBuilder({
required this.message,
required this.previousMessage,
required this.user,
required this.onPressUserProfile,
super.key,
});
/// The message that is being built
final MessageModel message;
/// The previous message if any, this can be used to determine if the message
/// is from the same sender as the previous message.
final MessageModel? previousMessage;
/// The user that sent the message
final UserModel user;
/// The function that is called when the user profile is pressed
final Function(UserModel user) onPressUserProfile;
/// implements [ChatMessageBuilder]
static Widget builder(
BuildContext context,
MessageModel message,
MessageModel? previousMessage,
UserModel user,
Function(UserModel user) onPressUserProfile,
) =>
OldChatMessageBuilder(
message: message,
previousMessage: previousMessage,
user: user,
onPressUserProfile: onPressUserProfile,
);
@override
Widget build(BuildContext context) {
var chatScope = ChatScope.of(context);
var options = chatScope.options;
var translations = options.translations;
var theme = Theme.of(context);
var dateFormatter = DateFormatter(options: options);
var isNewDate = previousMessage != null &&
message.timestamp.day != previousMessage?.timestamp.day;
var isSameSender = previousMessage == null ||
previousMessage?.senderId != message.senderId;
var isSameMinute = previousMessage != null &&
message.timestamp.minute == previousMessage?.timestamp.minute;
var hasHeader = isNewDate || isSameSender;
return Padding(
padding: EdgeInsets.only(
top: hasHeader ? 25.0 : 0,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hasHeader) ...[
InkWell(
onTap: () => onPressUserProfile(user),
child: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: user.imageUrl?.isNotEmpty ?? false
? _ChatImage(
image: user.imageUrl!,
)
: options.builders.userAvatarBuilder?.call(
context,
user,
40,
) ??
Avatar(
key: ValueKey(user.id),
boxfit: BoxFit.cover,
user: User(
firstName: user.firstName,
lastName: user.lastName,
imageUrl:
user.imageUrl != "" ? user.imageUrl : null,
),
size: 40,
),
),
),
] else ...[
const SizedBox(width: 50),
],
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 22.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
if (hasHeader) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: options.builders.usernameBuilder?.call(
user.fullname ?? "",
) ??
Text(
user.fullname ?? translations.anonymousUser,
style: theme.textTheme.titleMedium,
),
),
Padding(
padding: const EdgeInsets.only(top: 5.0),
child: Text(
dateFormatter.format(
date: message.timestamp,
showFullDate: true,
),
style: theme.textTheme.labelSmall,
),
),
],
),
],
Padding(
padding: const EdgeInsets.only(top: 3.0),
child: message.isTextMessage
? Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
message.text ?? "",
style: theme.textTheme.bodySmall,
),
),
if (!isSameMinute && !isNewDate && !hasHeader)
Text(
dateFormatter
.format(
date: message.timestamp,
showFullDate: true,
)
.split(" ")
.last,
style: theme.textTheme.labelSmall,
textAlign: TextAlign.end,
),
],
)
: message.isImageMessage
? CachedNetworkImage(
imageUrl: message.imageUrl ?? "",
)
: const SizedBox.shrink(),
),
],
),
),
),
],
),
);
}
}
class _ChatImage extends StatelessWidget {
const _ChatImage({
required this.image,
});
final String image;
@override
Widget build(BuildContext context) => Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(40.0),
),
width: 40,
height: 40,
child: image.isNotEmpty
? CachedNetworkImage(
fadeInDuration: Duration.zero,
imageUrl: image,
fit: BoxFit.cover,
)
: null,
);
}

View file

@ -1,667 +0,0 @@
import "dart:async";
import "dart:typed_data";
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/src/config/chat_options.dart";
import "package:flutter_chat/src/config/screen_types.dart";
import "package:flutter_chat/src/screens/creation/widgets/image_picker.dart";
import "package:flutter_chat/src/services/date_formatter.dart";
import "package:flutter_profile/flutter_profile.dart";
/// Chat detail screen
/// Seen when a user clicks on a chat
class ChatDetailScreen extends StatefulWidget {
/// Constructs a [ChatDetailScreen].
const ChatDetailScreen({
required this.userId,
required this.chatService,
required this.chatOptions,
required this.chat,
required this.onPressChatTitle,
required this.onPressUserProfile,
required this.onUploadImage,
required this.onMessageSubmit,
required this.onReadChat,
super.key,
});
/// The user ID of the person currently looking at the chat
final String userId;
/// The chat service associated with the widget.
final ChatService chatService;
/// The chat options
final ChatOptions chatOptions;
/// The chat model currently being viewed
final ChatModel chat;
/// Callback function triggered when the chat title is pressed.
final Function(ChatModel) onPressChatTitle;
/// Callback function triggered when the user profile is pressed.
final Function(UserModel) onPressUserProfile;
/// Callback function triggered when an image is uploaded.
final Function(Uint8List image) onUploadImage;
/// Callback function triggered when a message is submitted.
final Function(String text) onMessageSubmit;
/// Callback function triggered when the chat is read.
final Function(ChatModel chat) onReadChat;
@override
State<ChatDetailScreen> createState() => _ChatDetailScreenState();
}
class _ChatDetailScreenState extends State<ChatDetailScreen> {
String? chatTitle;
@override
void initState() {
if (widget.chat.isGroupChat) {
chatTitle = widget.chat.chatName ??
widget.chatOptions.translations.groupNameEmpty;
} else {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _getTitle();
});
}
super.initState();
}
Future<void> _getTitle() async {
var userId =
widget.chat.users.firstWhere((element) => element != widget.userId);
var user = await widget.chatService.getUser(userId: userId).first;
chatTitle = user.fullname ?? widget.chatOptions.translations.anonymousUser;
setState(() {});
}
@override
Widget build(BuildContext context) {
var appBar = _AppBar(
chatTitle: chatTitle,
chatOptions: widget.chatOptions,
onPressChatTitle: widget.onPressChatTitle,
chatModel: widget.chat,
);
var body = _Body(
chatService: widget.chatService,
options: widget.chatOptions,
chat: widget.chat,
currentUserId: widget.userId,
onPressUserProfile: widget.onPressUserProfile,
onUploadImage: widget.onUploadImage,
onMessageSubmit: widget.onMessageSubmit,
onReadChat: widget.onReadChat,
);
if (widget.chatOptions.builders.baseScreenBuilder == null) {
return Scaffold(
appBar: appBar,
body: body,
);
}
return widget.chatOptions.builders.baseScreenBuilder!.call(
context,
widget.mapScreenType,
appBar,
body,
);
}
}
class _AppBar extends StatelessWidget implements PreferredSizeWidget {
const _AppBar({
required this.chatTitle,
required this.chatOptions,
required this.onPressChatTitle,
required this.chatModel,
});
final String? chatTitle;
final ChatOptions chatOptions;
final Function(ChatModel) onPressChatTitle;
final ChatModel chatModel;
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return AppBar(
iconTheme: theme.appBarTheme.iconTheme ??
const IconThemeData(color: Colors.white),
centerTitle: true,
leading: GestureDetector(
onTap: () {
Navigator.popUntil(context, (route) => route.isFirst);
},
child: const Icon(
Icons.arrow_back_ios,
),
),
title: GestureDetector(
onTap: () => onPressChatTitle.call(chatModel),
child: chatOptions.builders.chatTitleBuilder?.call(chatTitle ?? "") ??
Text(
chatTitle ?? "",
overflow: TextOverflow.ellipsis,
),
),
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
class _Body extends StatefulWidget {
const _Body({
required this.chatService,
required this.options,
required this.chat,
required this.currentUserId,
required this.onPressUserProfile,
required this.onUploadImage,
required this.onMessageSubmit,
required this.onReadChat,
});
final ChatService chatService;
final ChatOptions options;
final String currentUserId;
final ChatModel chat;
final Function(UserModel) onPressUserProfile;
final Function(Uint8List image) onUploadImage;
final Function(String message) onMessageSubmit;
final Function(ChatModel chat) onReadChat;
@override
State<_Body> createState() => _BodyState();
}
class _BodyState extends State<_Body> {
final ScrollController controller = ScrollController();
bool showIndicator = false;
late int pageSize;
int page = 0;
@override
void initState() {
pageSize = widget.options.pageSize;
super.initState();
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
void handleScroll(PointerMoveEvent event) {
if (!showIndicator &&
controller.offset >= controller.position.maxScrollExtent &&
!controller.position.outOfRange) {
setState(() {
showIndicator = true;
});
setState(() {
page++;
});
Future.delayed(const Duration(seconds: 2), () {
if (mounted) {
setState(() {
showIndicator = false;
});
}
});
}
}
return Stack(
children: [
Column(
children: [
Expanded(
child: StreamBuilder<List<MessageModel>?>(
stream: widget.chatService.getMessages(
userId: widget.currentUserId,
chatId: widget.chat.id,
pageSize: pageSize,
page: page,
),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
var messages = snapshot.data?.reversed.toList() ?? [];
WidgetsBinding.instance.addPostFrameCallback((_) async {
await widget.onReadChat(widget.chat);
});
return Listener(
onPointerMove: handleScroll,
child: ListView(
shrinkWrap: true,
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
reverse: messages.isNotEmpty,
padding: const EdgeInsets.only(top: 24.0),
children: [
if (messages.isEmpty && !showIndicator) ...[
Center(
child: Text(
widget.chat.isGroupChat
? widget.options.translations
.writeFirstMessageInGroupChat
: widget.options.translations
.writeMessageToStartChat,
style: theme.textTheme.bodySmall,
),
),
],
for (var i = 0; i < messages.length; i++) ...[
if (widget.chat.id == messages[i].chatId) ...[
_ChatBubble(
key: ValueKey(messages[i].id),
message: messages[i],
previousMessage: i < messages.length - 1
? messages[i + 1]
: null,
chatService: widget.chatService,
onPressUserProfile: widget.onPressUserProfile,
options: widget.options,
),
],
],
],
),
);
},
),
),
_ChatBottom(
chat: widget.chat,
onPressSelectImage: () async => onPressSelectImage.call(
context,
widget.options,
widget.onUploadImage,
),
onMessageSubmit: widget.onMessageSubmit,
options: widget.options,
),
],
),
if (showIndicator) ...[
widget.options.builders.loadingWidgetBuilder?.call(context) ??
const Column(
children: [
SizedBox(
height: 10,
),
Center(
child: CircularProgressIndicator(),
),
SizedBox(
height: 10,
),
],
),
],
],
);
}
}
class _ChatBottom extends StatefulWidget {
const _ChatBottom({
required this.chat,
required this.onMessageSubmit,
required this.options,
this.onPressSelectImage,
});
/// Callback function invoked when a message is submitted.
final Function(String text) onMessageSubmit;
/// Callback function invoked when the select image button is pressed.
final VoidCallback? onPressSelectImage;
/// The chat model.
final ChatModel chat;
final ChatOptions options;
@override
State<_ChatBottom> createState() => _ChatBottomState();
}
class _ChatBottomState extends State<_ChatBottom> {
final TextEditingController _textEditingController = TextEditingController();
bool _isTyping = false;
bool _isSending = false;
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
_textEditingController.addListener(() {
setState(() {
_isTyping = _textEditingController.text.isNotEmpty;
});
});
Future<void> sendMessage() async {
setState(() {
_isSending = true;
});
var value = _textEditingController.text;
if (value.isNotEmpty) {
await widget.onMessageSubmit(value);
_textEditingController.clear();
}
setState(() {
_isSending = false;
});
}
Future<void> Function()? onClickSendMessage;
if (_isTyping && !_isSending) {
onClickSendMessage = () async => sendMessage();
}
var messageSendButtons = Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: widget.onPressSelectImage,
icon: Icon(
Icons.image_outlined,
color: widget.options.iconEnabledColor,
),
),
IconButton(
disabledColor: widget.options.iconDisabledColor,
color: widget.options.iconEnabledColor,
onPressed: onClickSendMessage,
icon: const Icon(
Icons.send,
),
),
],
);
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 16,
),
child: SizedBox(
height: 45,
child: widget.options.builders.messageInputBuilder?.call(
context,
_textEditingController,
messageSendButtons,
widget.options.translations,
) ??
TextField(
textAlign: TextAlign.start,
textAlignVertical: TextAlignVertical.center,
style: theme.textTheme.bodySmall,
textCapitalization: TextCapitalization.sentences,
controller: _textEditingController,
decoration: InputDecoration(
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: const BorderSide(
color: Colors.black,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: const BorderSide(
color: Colors.black,
),
),
contentPadding: const EdgeInsets.symmetric(
vertical: 0,
horizontal: 30,
),
hintText: widget.options.translations.messagePlaceholder,
hintStyle: theme.textTheme.bodyMedium,
fillColor: Colors.white,
filled: true,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(25),
),
borderSide: BorderSide.none,
),
suffixIcon: messageSendButtons,
),
),
),
);
}
}
class _ChatBubble extends StatefulWidget {
const _ChatBubble({
required this.message,
required this.chatService,
required this.onPressUserProfile,
required this.options,
this.previousMessage,
super.key,
});
final ChatOptions options;
final ChatService chatService;
final MessageModel message;
final MessageModel? previousMessage;
final Function(UserModel user) onPressUserProfile;
@override
State<_ChatBubble> createState() => _ChatBubbleState();
}
class _ChatBubbleState extends State<_ChatBubble> {
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
var translations = widget.options.translations;
var dateFormatter = DateFormatter(options: widget.options);
var isNewDate = widget.previousMessage != null &&
widget.message.timestamp.day != widget.previousMessage?.timestamp.day;
var isSameSender = widget.previousMessage == null ||
widget.previousMessage?.senderId != widget.message.senderId;
var isSameMinute = widget.previousMessage != null &&
widget.message.timestamp.minute ==
widget.previousMessage?.timestamp.minute;
var hasHeader = isNewDate || isSameSender;
return StreamBuilder<UserModel>(
stream: widget.chatService.getUser(userId: widget.message.senderId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
var user = snapshot.data!;
return widget.options.builders.chatMessageBuilder?.call(
context,
widget.message,
widget.previousMessage,
) ??
Padding(
padding: EdgeInsets.only(
top: isNewDate || isSameSender ? 25.0 : 0,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isNewDate || isSameSender) ...[
InkWell(
onTap: () => widget.onPressUserProfile(user),
child: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: user.imageUrl?.isNotEmpty ?? false
? _ChatImage(
image: user.imageUrl!,
)
: widget.options.builders.userAvatarBuilder?.call(
context,
user,
40,
) ??
Avatar(
key: ValueKey(user.id),
boxfit: BoxFit.cover,
user: User(
firstName: user.firstName,
lastName: user.lastName,
imageUrl: user.imageUrl != ""
? user.imageUrl
: null,
),
size: 40,
),
),
),
] else ...[
const SizedBox(
width: 50,
),
],
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 22.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
if (isNewDate || isSameSender) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: widget.options.builders.usernameBuilder
?.call(
user.fullname ?? "",
) ??
Text(
user.fullname ??
translations.anonymousUser,
style: theme.textTheme.titleMedium,
),
),
Padding(
padding: const EdgeInsets.only(top: 5.0),
child: Text(
dateFormatter.format(
date: widget.message.timestamp,
showFullDate: true,
),
style: theme.textTheme.labelSmall,
),
),
],
),
],
Padding(
padding: const EdgeInsets.only(top: 3.0),
child: widget.message.isTextMessage
? Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
widget.message.text ?? "",
style: theme.textTheme.bodySmall,
),
),
if (widget.options.showTimes &&
!isSameMinute &&
!isNewDate &&
!hasHeader)
Text(
dateFormatter
.format(
date: widget.message.timestamp,
showFullDate: true,
)
.split(" ")
.last,
style: theme.textTheme.labelSmall,
textAlign: TextAlign.end,
),
],
)
: widget.message.isImageMessage
? CachedNetworkImage(
imageUrl: widget.message.imageUrl ?? "",
)
: const SizedBox.shrink(),
),
],
),
),
),
],
),
);
},
);
}
}
class _ChatImage extends StatelessWidget {
const _ChatImage({
required this.image,
});
final String image;
@override
Widget build(BuildContext context) => Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(40.0),
),
width: 40,
height: 40,
child: image.isNotEmpty
? CachedNetworkImage(
fadeInDuration: Duration.zero,
imageUrl: image,
fit: BoxFit.cover,
)
: null,
);
}