← Назад к вопросам

Реализовать чат-интерфейс

1.7 Middle🔥 161 комментариев
#Flutter виджеты#State Management#Работа с сетью

Условие

Создайте UI чата с отображением сообщений.

Требования

  1. Список сообщений с разделением на входящие/исходящие
  2. Поле ввода сообщения
  3. Кнопка отправки
  4. Автоскролл к новым сообщениям
  5. Время отправки у каждого сообщения

Модель сообщения

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

Этот чат обеспечивает:

  • Полную функциональность — текстовые и графические сообщения
  • Статусы доставки — отправлено, доставлено, прочитано
  • Индикатор печати — уведомление о печати
  • Группировка по датам — удобная навигация
  • Оффлайн поддержка — кэширование сообщений
  • Плавная анимация — автоскролл и переходы
Реализовать чат-интерфейс | PrepBro