← Назад к вопросам
Реализовать чат-интерфейс
1.7 Middle🔥 161 комментариев
#Flutter виджеты#State Management#Работа с сетью
Условие
Создайте UI чата с отображением сообщений.
Требования
- Список сообщений с разделением на входящие/исходящие
- Поле ввода сообщения
- Кнопка отправки
- Автоскролл к новым сообщениям
- Время отправки у каждого сообщения
Модель сообщения
class Message {
final String id;
final String text;
final DateTime timestamp;
final bool isMe;
}
Дополнительные баллы
- Отображение "печатает..." (typing indicator)
- Статус доставки сообщения (отправлено, доставлено, прочитано)
- Группировка сообщений по дате
- Отправка изображений
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Решение: Чат-интерфейс на Flutter
Архитектура и структура
Построим полнофункциональный чат с поддержкой статусов сообщений, индикаторов печати и группировкой по датам.
Модель сообщения и статусы
// domain/entities/message.dart
enum MessageStatus { sending, sent, delivered, read }
enum MessageType { text, image }
class Message {
final String id;
final String text;
final DateTime timestamp;
final bool isMe;
final MessageStatus status;
final MessageType type;
final String? imageUrl;
final String senderId;
final String recipientId;
Message({
required this.id,
required this.text,
required this.timestamp,
required this.isMe,
this.status = MessageStatus.sent,
this.type = MessageType.text,
this.imageUrl,
required this.senderId,
required this.recipientId,
});
Message copyWith({
String? id,
String? text,
DateTime? timestamp,
bool? isMe,
MessageStatus? status,
MessageType? type,
String? imageUrl,
String? senderId,
String? recipientId,
}) {
return Message(
id: id ?? this.id,
text: text ?? this.text,
timestamp: timestamp ?? this.timestamp,
isMe: isMe ?? this.isMe,
status: status ?? this.status,
type: type ?? this.type,
imageUrl: imageUrl ?? this.imageUrl,
senderId: senderId ?? this.senderId,
recipientId: recipientId ?? this.recipientId,
);
}
}
// Группировка сообщений по дате
class MessageGroup {
final DateTime date;
final List<Message> messages;
MessageGroup({
required this.date,
required this.messages,
});
}
Контроллер чата
// presentation/controllers/chat_controller.dart
class ChatController extends GetxController {
final ChatRepository _repository;
final String conversationId;
final messages = <Message>[].obs;
final messageGroups = <MessageGroup>[].obs;
final isLoading = false.obs;
final isTyping = false.obs;
final messageController = TextEditingController();
final scrollController = ScrollController();
ChatController({
required ChatRepository repository,
required this.conversationId,
}) : _repository = repository;
@override
void onInit() {
super.onInit();
_loadMessages();
_listenToNewMessages();
_listenToTypingIndicator();
}
Future<void> _loadMessages() async {
isLoading.value = true;
try {
final loadedMessages = await _repository.getMessages(conversationId);
messages.assignAll(loadedMessages);
_groupMessagesByDate();
_scrollToBottom();
} catch (e) {
Get.snackbar("Ошибка", "Не удалось загрузить сообщения");
} finally {
isLoading.value = false;
}
}
void _groupMessagesByDate() {
final Map<String, List<Message>> grouped = {};
for (var message in messages) {
final dateKey = _formatDateKey(message.timestamp);
if (!grouped.containsKey(dateKey)) {
grouped[dateKey] = [];
}
grouped[dateKey]!.add(message);
}
final groups = grouped.entries.map((entry) {
return MessageGroup(
date: _parseDateKey(entry.key),
messages: entry.value,
);
}).toList();
messageGroups.assignAll(groups);
}
void _listenToNewMessages() {
_repository.messagesStream(conversationId).listen((newMessage) {
messages.add(newMessage);
_groupMessagesByDate();
_scrollToBottom();
// Отмечаем сообщение как прочитанное
if (!newMessage.isMe) {
_markMessageAsRead(newMessage.id);
}
});
}
void _listenToTypingIndicator() {
_repository.typingStream(conversationId).listen((isTypingNow) {
isTyping.value = isTypingNow;
});
}
/// Отправка текстового сообщения
Future<void> sendMessage(String text) async {
if (text.trim().isEmpty) return;
final messageText = messageController.text;
messageController.clear();
// Создаём локальное сообщение
final tempMessage = Message(
id: 'temp_${DateTime.now().millisecondsSinceEpoch}',
text: messageText,
timestamp: DateTime.now(),
isMe: true,
status: MessageStatus.sending,
type: MessageType.text,
senderId: 'current_user_id',
recipientId: 'recipient_id',
);
messages.add(tempMessage);
_groupMessagesByDate();
_scrollToBottom();
try {
final sentMessage = await _repository.sendMessage(
conversationId,
messageText,
);
// Заменяем временное сообщение на реальное
final index = messages.indexWhere((m) => m.id == tempMessage.id);
if (index != -1) {
messages[index] = sentMessage;
}
_groupMessagesByDate();
} catch (e) {
// Показываем ошибку и восстанавливаем текст
messageController.text = messageText;
messages.removeWhere((m) => m.id == tempMessage.id);
Get.snackbar("Ошибка", "Не удалось отправить сообщение");
}
}
/// Отправка изображения
Future<void> sendImage(String imagePath) async {
final tempMessage = Message(
id: 'temp_${DateTime.now().millisecondsSinceEpoch}',
text: '[Изображение]',
timestamp: DateTime.now(),
isMe: true,
status: MessageStatus.sending,
type: MessageType.image,
imageUrl: imagePath,
senderId: 'current_user_id',
recipientId: 'recipient_id',
);
messages.add(tempMessage);
_groupMessagesByDate();
_scrollToBottom();
try {
final sentMessage = await _repository.sendImage(
conversationId,
imagePath,
);
final index = messages.indexWhere((m) => m.id == tempMessage.id);
if (index != -1) {
messages[index] = sentMessage;
}
_groupMessagesByDate();
} catch (e) {
messages.removeWhere((m) => m.id == tempMessage.id);
Get.snackbar("Ошибка", "Не удалось отправить изображение");
}
}
/// Отправка индикатора печати
void notifyTyping() {
_repository.notifyTyping(conversationId);
}
/// Отмечаем сообщение как прочитанное
Future<void> _markMessageAsRead(String messageId) async {
try {
await _repository.markMessageAsRead(messageId);
final index = messages.indexWhere((m) => m.id == messageId);
if (index != -1) {
messages[index] = messages[index].copyWith(
status: MessageStatus.read,
);
}
} catch (e) {
// Логируем молча
}
}
/// Автоскролл к последнему сообщению
void _scrollToBottom() {
Future.delayed(Duration(milliseconds: 100), () {
if (scrollController.hasClients) {
scrollController.animateTo(
scrollController.position.maxScrollExtent,
duration: Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
/// Форматирование времени сообщения
String formatMessageTime(DateTime dateTime) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final messageDate = DateTime(
dateTime.year,
dateTime.month,
dateTime.day,
);
if (messageDate == today) {
return DateFormat('HH:mm').format(dateTime);
} else if (messageDate == today.subtract(Duration(days: 1))) {
return 'Вчера ${DateFormat("HH:mm").format(dateTime)}';
} else {
return DateFormat('dd.MM.yyyy HH:mm').format(dateTime);
}
}
String _formatDateKey(DateTime dateTime) {
return DateFormat('yyyy-MM-dd').format(dateTime);
}
DateTime _parseDateKey(String key) {
return DateTime.parse(key);
}
@override
void onClose() {
messageController.dispose();
scrollController.dispose();
super.onClose();
}
}
Главная страница чата
// presentation/pages/chat_page.dart
class ChatPage extends StatefulWidget {
final String conversationId;
final String conversationName;
const ChatPage({
required this.conversationId,
required this.conversationName,
});
@override
_ChatPageState createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
late ChatController _controller;
@override
void initState() {
super.initState();
_controller = Get.put(
ChatController(
repository: ChatRepository(),
conversationId: widget.conversationId,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.conversationName),
elevation: 0,
backgroundColor: Color(0xFF667EEA),
),
body: Column(
children: [
Expanded(
child: Obx(() {
if (_controller.isLoading.value) {
return Center(
child: CircularProgressIndicator(color: Color(0xFF667EEA)),
);
}
if (_controller.messageGroups.isEmpty) {
return Center(
child: Text(
"Нет сообщений",
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
controller: _controller.scrollController,
itemCount: _controller.messageGroups.length,
itemBuilder: (context, groupIndex) {
final group = _controller.messageGroups[groupIndex];
return Column(
children: [
_buildDateDivider(group.date),
...group.messages.map((message) {
return _buildMessageBubble(message);
}).toList(),
],
);
},
);
}),
),
Obx(() {
if (_controller.isTyping.value) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
"Печатает...",
style: TextStyle(color: Colors.grey, fontSize: 12),
),
);
}
return SizedBox.shrink();
}),
_buildMessageInput(),
],
),
);
}
Widget _buildDateDivider(DateTime date) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Row(
children: [
Expanded(
child: Divider(color: Colors.grey[300]),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: Text(
_formatDateDivider(date),
style: TextStyle(
color: Colors.grey[600],
fontSize: 12,
),
),
),
Expanded(
child: Divider(color: Colors.grey[300]),
),
],
),
);
}
Widget _buildMessageBubble(Message message) {
return Align(
alignment: message.isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.7,
),
child: Column(
crossAxisAlignment: message.isMe
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: message.isMe
? Color(0xFF667EEA)
: Colors.grey[200],
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
bottomLeft: Radius.circular(
message.isMe ? 12 : 0,
),
bottomRight: Radius.circular(
message.isMe ? 0 : 12,
),
),
),
child: message.type == MessageType.image
? _buildImageMessage(message)
: Text(
message.text,
style: TextStyle(
color: message.isMe ? Colors.white : Colors.black,
fontSize: 14,
),
),
),
SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_controller.formatMessageTime(message.timestamp),
style: TextStyle(
color: Colors.grey[600],
fontSize: 11,
),
),
if (message.isMe)
Padding(
padding: EdgeInsets.only(left: 4),
child: _buildStatusIcon(message.status),
),
],
),
],
),
),
);
}
Widget _buildImageMessage(Message message) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
message.imageUrl ?? '',
width: 200,
height: 200,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 200,
height: 200,
color: Colors.grey[300],
child: Icon(Icons.image_not_supported),
);
},
),
);
}
Widget _buildStatusIcon(MessageStatus status) {
switch (status) {
case MessageStatus.sending:
return Icon(Icons.schedule, size: 12, color: Colors.grey);
case MessageStatus.sent:
return Icon(Icons.done, size: 12, color: Colors.grey);
case MessageStatus.delivered:
return Icon(Icons.done_all, size: 12, color: Colors.grey);
case MessageStatus.read:
return Icon(Icons.done_all, size: 12, color: Colors.blue);
}
}
Widget _buildMessageInput() {
return Container(
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(color: Colors.grey[300]!),
),
),
child: SafeArea(
child: Row(
children: [
IconButton(
onPressed: _pickImage,
icon: Icon(Icons.image, color: Color(0xFF667EEA)),
),
Expanded(
child: TextField(
controller: _controller.messageController,
onChanged: (_) => _controller.notifyTyping(),
decoration: InputDecoration(
hintText: "Сообщение...",
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Colors.grey[100],
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
maxLines: null,
),
),
SizedBox(width: 4),
FloatingActionButton(
mini: true,
onPressed: () => _controller.sendMessage(
_controller.messageController.text,
),
backgroundColor: Color(0xFF667EEA),
child: Icon(Icons.send, size: 18),
),
],
),
),
);
}
void _pickImage() async {
final picker = ImagePicker();
final pickedFile = await picker.pickImage(source: ImageSource.gallery);
if (pickedFile != null) {
await _controller.sendImage(pickedFile.path);
}
}
String _formatDateDivider(DateTime date) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final messageDate = DateTime(date.year, date.month, date.day);
if (messageDate == today) {
return "Сегодня";
} else if (messageDate == today.subtract(Duration(days: 1))) {
return "Вчера";
} else {
return DateFormat('dd MMMM yyyy', 'ru_RU').format(date);
}
}
@override
void dispose() {
Get.delete<ChatController>();
super.dispose();
}
}
Repository и DataSource
// data/repositories/chat_repository.dart
class ChatRepository {
final ChatRemoteDataSource _remoteDataSource = ChatRemoteDataSource();
final ChatLocalDataSource _localDataSource = ChatLocalDataSource();
Future<List<Message>> getMessages(String conversationId) async {
try {
// Сначала загружаем из кэша
final cachedMessages = await _localDataSource.getMessages(conversationId);
if (cachedMessages.isNotEmpty) {
return cachedMessages;
}
// Затем с сервера
final messages = await _remoteDataSource.getMessages(conversationId);
await _localDataSource.cacheMessages(conversationId, messages);
return messages;
} catch (e) {
return await _localDataSource.getMessages(conversationId);
}
}
Stream<Message> messagesStream(String conversationId) {
return _remoteDataSource.messagesStream(conversationId);
}
Stream<bool> typingStream(String conversationId) {
return _remoteDataSource.typingStream(conversationId);
}
Future<Message> sendMessage(String conversationId, String text) async {
final message = await _remoteDataSource.sendMessage(
conversationId,
text,
);
await _localDataSource.cacheMessage(conversationId, message);
return message;
}
Future<Message> sendImage(String conversationId, String imagePath) async {
final message = await _remoteDataSource.sendImage(
conversationId,
imagePath,
);
await _localDataSource.cacheMessage(conversationId, message);
return message;
}
Future<void> notifyTyping(String conversationId) async {
await _remoteDataSource.notifyTyping(conversationId);
}
Future<void> markMessageAsRead(String messageId) async {
await _remoteDataSource.markMessageAsRead(messageId);
}
}
Dependencies в pubspec.yaml
dependencies:
flutter:
sdk: flutter
get: ^4.6.5
http: ^1.1.0
image_picker: ^1.0.0
intl: ^0.19.0
hive: ^2.2.3
hive_flutter: ^1.1.0
dev_dependencies:
hive_generator: ^2.0.0
build_runner: ^2.4.0
Этот чат обеспечивает:
- Полную функциональность — текстовые и графические сообщения
- Статусы доставки — отправлено, доставлено, прочитано
- Индикатор печати — уведомление о печати
- Группировка по датам — удобная навигация
- Оффлайн поддержка — кэширование сообщений
- Плавная анимация — автоскролл и переходы