Criando um Chat Completo Usando Flutter e Firebase – Parte 1
Está cada vez mais comum acompanharmos o surgimento de novos aplicativos de interação social sendo criados por aí, e por isso, decidi criar um chat usando Flutter e Firebase.
Pensei que seria bem legal e desafiador escrever uma aplicação de conversa em tempo real e depois transformar em artigo para que mais pessoas pudessem acompanhar o desenvolvimento de algo desse tipo.
Esse artigo será dividido em 3 partes: chat, envio de mídia e confirmação de leitura.
Primeiro, vamos escolher o nome do nosso aplicativo. O nome do meu será “MyChat”, por ser um nome curto e objetivo.
Vamos criar o projeto Flutter, para isso digite o comando:
flutter create mychat
Depois que o nosso projeto for gerado, vamos acessar o diretório para podermos instalar algumas dependências do Firebase.
cd mychat
Adicionando as dependências e configurando o Firebase
Agora iremos adicionar nas dependências do projeto os pacotes firebase_core e cloud_firestore, pacotes importantes para podermos configurar e nos comunicar com o Firebase.
flutter pub add firebase_core
flutter pub add cloud_firestore
Também vamos adicionar a dependência que vai nos ajudar no momento de trabalhar com datas:
flutter pub add intl
Agora, precisamos instalar o Firebase CLI para preparar o nosso app com as configurações do nosso projeto no Firebase.
dart pub global activate flutterfire_cli
Antes de seguirmos, precisamos fazer a autenticação no Firebase CLI que acabamos de instalar, digite o comando:
firebase login
Agora que estamos autenticados no Firebase CLI, precisamos gerar o arquivo de configuração do Firebase para que a integração seja feita de fato. Para isto, precisamos digitar o seguinte comando:
flutterfire configure
Atenção: Se você estiver recebendo uma mensagem de erro com a mensagem “uses-sdk:minSdkVersion 16 cannot be smaller than version 19 declared in library [:cloud_firestore]”, vá ao arquivo android/app/build.gradle e mude a versão do minSdkVersion de 16 para 21.
Note que será apresentado alguns passos para preenchermos com informações que ficarão registradas no nosso arquivo de configuração firebase_options.dart que será gerado no final.
Preencheremos da seguinte forma:
Select a Firebase project to configure your Flutter application with: Escolha a opção <create a new project>
Enter a project id for your new Firebase project: Dê um nome único ao seu projeto no Firebase, o meu ficará my-chat-[id_aleatório].
Which platforms should your configuration support: Nosso tutorial será feito no Android, então só selecionarei Android.
No final será perguntado se você deseja atualizar os arquivos build.gradle, é só pressionar Enter para aceitar e finalizar.
Um arquivo firebase_options.dart foi criado na raiz da estrutura do projeto, nele fica reunido todas as informações necessárias para se conectar com o nosso projeto no FIrebase.
Agora precisamos ir no arquivo lib/main.dart e inserir o código que inicializa o Firebase na aplicação. Acrescente o seguinte bloco de código no método main(). Não esqueça de colocar o async no método main() e de importar o pacote firebase_core e o arquivo firebase_options que se encontra na pasta /lib.
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const MyApp());
}
Acabamos de colocar o método que inicializa o Firebase na nossa aplicação, agora precisamos criar a model da mensagem.
Model
No diretório /lib, crie uma pasta chamada models e dentro dela crie um arquivo chamado message_model.dart. A classe da nossa model terá três propriedades, são elas: author, message e timestamp. O código ficará assim:
import 'package:cloud_firestore/cloud_firestore.dart';
class MessageModel {
String author;
String message;
Timestamp timestamp;
MessageModel(
{required this.author, required this.message, required this.timestamp});
Map<String, dynamic> toJson() {
return {
'author': author,
'message': message,
'timestamp': timestamp,
};
}
factory MessageModel.fromDocument(DocumentSnapshot documentSnapshot) {
String author = documentSnapshot.get('author');
String message = documentSnapshot.get('message');
Timestamp timestamp = documentSnapshot.get('timestamp');
return MessageModel(author: author, message: message, timestamp: timestamp);
}
}
Provider
Depois da nossa model criada, vamos escrever o código do nosso provider. O provider ficará responsável pelas requisições feitas no Firestore, é nele que criaremos os métodos que envia as mensagens e que recupera as mensagens enviadas.
No diretório /lib, crie uma pasta chamada providers e dentro dela crie um arquivo chamado chat_provider.dart.
O código ficará da seguinte forma:
import 'package:cloud_firestore/cloud_firestore.dart';
import '../models/message_model.dart';
class ChatProvider {
final FirebaseFirestore firebaseFirestore;
ChatProvider({required this.firebaseFirestore});
Stream<QuerySnapshot> getMessageList() {
return firebaseFirestore
.collection('messages')
.orderBy('timestamp', descending: true)
.snapshots();
}
void sendMessage(String message, String author) {
MessageModel chatMessages = MessageModel(
author: author,
timestamp: Timestamp.now(),
message: message,
);
firebaseFirestore.collection('messages').add(chatMessages.toJson());
}
}
Tela da “sala de entrada”
No diretório lib, crie uma pasta chamada “screens” e nela crie um arquivo “enter_room_screen.dart”, ficando da seguinte forma: lib/screens/enter_room_screen.dart. Nessa tela o usuário poderá inserir um nickname no input que criamos, logo abaixo terá um botão para ele entrar na sala do chat.
O código ficará da seguinte forma:
import 'package:flutter/material.dart';
class EnterRoomScreen extends StatelessWidget {
EnterRoomScreen({Key? key}) : super(key: key);
final TextEditingController _nicknameEditingController =
TextEditingController();
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
backgroundColor: const Color(0XFF23272a),
body: Center(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'Seu nickname é...',
style: TextStyle(fontSize: 20, color: Colors.white),
),
const SizedBox(height: 12),
_nicknameInput(),
const SizedBox(height: 12),
_enterButton(context),
],
),
),
),
),
);
}
Widget _nicknameInput() {
return TextFormField(
textInputAction: TextInputAction.done,
controller: _nicknameEditingController,
onChanged: (value) {},
cursorColor: const Color(0xff9b84ec),
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.never,
contentPadding: EdgeInsets.all(20.0),
filled: true,
fillColor: Color(0xff2f3136),
labelText: 'Nickname',
suffixText: 'Nickname',
hintStyle: TextStyle(color: Colors.white54),
labelStyle: TextStyle(color: Colors.white54),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.black26),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Color(0xff9b84ec), width: 1),
borderRadius: BorderRadius.all(Radius.circular(8.0)),
),
border: OutlineInputBorder(
borderSide: BorderSide(color: Colors.red, width: 5),
borderRadius: BorderRadius.all(Radius.circular(8.0)),
gapPadding: 8.0,
),
),
);
}
Widget _enterButton(context) {
return Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () {
Navigator.pushNamed(context, '/chat',
arguments: _nicknameEditingController.text);
_nicknameEditingController.clear();
},
child: const Text('Entrar'),
style: ElevatedButton.styleFrom(
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
primary: const Color(0xff9b84ec),
),
),
),
],
);
}
}
No código acima montamos uma tela que terá um título, um campo de texto para o usuário inserir o nickname e um campo para entrar na sala.
Agora vou explicar o que cada parte do código faz:
_nicknameEditingController: Controller que salvará o valor do campo nickname
_nicknameInput: Cria um widget com um campo de texto
_enterButton: Botão para entrar no chat, onde seu evento principal é redirecionar o usuário para a rota “/chat” e enviar o valor do nickname como argumento. Logo após o usuário ser redirecionado, o campo nickname é limpo usando o método clear() do nicknameEditingController.
Você deve estar se perguntando, “mas que rota é essa?“…bom, mais pra frente eu explico.
Tela da conversa
Criaremos agora a tela do chat, onde o usuário poderá visualizar todas as mensagens, digitar mensagens e enviar.
No diretório lib/screens, crie um arquivo chamado chat_screen.dart.
Na nossa classe iremos criar uma instância do Firestore:
final ChatProvider = ChatProvider(firebaseFirestore: FirebaseFirestore.instance);
Depois precisamos criar um controlador de input para armazenar a mensagem que o usuário pretende enviar:
final TextEditingController messageEditingController = TextEditingController();
Agora, temos que criar a variável onde será armazenada as mensagens, assim que tivermos um retorno da nossa requisição no Firebase:
List<QueryDocumentSnapshot> messageList = [];
Até o momento nossa classe se parecerá com isso:
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../providers/chat_provider.dart';
class ChatScreen extends StatefulWidget {
const ChatScreen({Key? key}) : super(key: key);
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final ChatProvider chatProvider =
ChatProvider(firebaseFirestore: FirebaseFirestore.instance);
final TextEditingController messageEditingController =
TextEditingController();
List<QueryDocumentSnapshot> messageList = [];
final ScrollController scrollController = ScrollController();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0XFF36393f),
appBar: AppBar(
centerTitle: true,
backgroundColor: const Color(0XFF23272a),
elevation: 1,
title: const Text('My.chat', style: TextStyle(fontSize: 16)),
),
body: Stack(
children: const <Widget>[
],
),
);
}
}
Agora precisamos criar dois métodos, um que será responsável pelo envio das mensagens e outro para pegarmos o nickname do usuário.
sendMessage: Responsável por registrar as mensagens no Firestore,
onde receberá o texto da mensagem como seu único argumento. Além de criar novos registros no Firebase, a função fica responsável por limpar nosso input de mensagem e também de descer o scroll para a base de uma forma suavizada.
Perceba que antes de enviar a mensagem, nós verificamos se o campo da mensagem está vazio, se estiver, o método de enviar não é chamado. Também, usamos a função trim() ao enviar a mensagem, para que espaços em brancos antes e depois da mensagem sejam removidos.
currentUser: Nos retorna o nickname que foi passado como argumento para a rota.
Nosso método sendMessage ficará assim:
void sendMessage(String message) {
if (message.isNotEmpty) {
messageEditingController.clear();
chatProvider.sendMessage(message.trim(), currentUser(context));
scrollController.animateTo(0,
duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
}
}
E o nosso método de pegar o nickname do usuário ficará desse jeito:
currentUser(context) => ModalRoute.of(context)?.settings.arguments as String;
Depois dessas adições, nosso código ficará assim:
import 'package:cloud_firestore/cloud_firestore.dart';
import '../providers/chat_provider.dart';
class ChatScreen extends StatefulWidget {
const ChatScreen({Key? key}) : super(key: key);
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final ChatProvider chatProvider = ChatProvider(firebaseFirestore: FirebaseFirestore.instance);
final TextEditingController messageEditingController = TextEditingController();
List<QueryDocumentSnapshot> messageList = [];
final ScrollController scrollController = ScrollController();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0XFF36393f),
appBar: AppBar(
centerTitle: true,
backgroundColor: const Color(0XFF23272a),
elevation: 1,
title: const Text(‘My.chat’, style: TextStyle(fontSize: 16)),
),
body: Stack(
children: const <Widget>[
],
),
);
}
}
Nada será renderizado na nossa tela, ainda. Precisamos criar nossos widgets.
Veja na imagem abaixo quais widgets iremos criar:
Começaremos criando nosso widget do timestamp, como o nome já diz será um widget para exibir a data e hora que a mensagem foi enviada. No diretório lib, crie um diretório chamado widgets, dentro deste diretório crie um arquivo com o nome message_timestamp.dart. Nosso código ficará assim:
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class MessageTimestampWidget extends StatelessWidget {
const MessageTimestampWidget({
Key? key,
required this.timestamp,
}) : super(key: key);
final Timestamp timestamp;
@override
Widget build(BuildContext context) {
const datePattern = 'dd MMM yyyy, HH:mm';
final timestampFormatted =
DateFormat(datePattern).format(timestamp.toDate());
return Container(
margin: const EdgeInsets.symmetric(horizontal: 15, vertical: 10),
child: Text(
timestampFormatted,
style: const TextStyle(
color: Colors.grey, fontSize: 12, fontStyle: FontStyle.italic),
),
);
}
}
Perceba que nosso widget recebe um argumento com o timestamp que irá ser exibido logo após formatarmos usando o DateFormat do Intl. Na linha anterior a esta, criamos uma constante chamada datePattern para determinarmos como queremos que o timestamp seja formatado.
Agora, criaremos nosso widget do message bubble. No diretório lib/widgets, crie um arquivo chamado message_bubble.dart.
Nosso widget recebe dois argumentos: chatMessage e isMe.
chatMessage: um objeto da mensagem com as propriedades author, timestamp e message.
isMe: um boolean para determinar se a mensagem está sendo enviada ou recebida. Isto vai nos ajudar a diferenciar o próprio widget, estilizando e posicionando corretamente.
O código ficará assim:
import 'package:flutter/material.dart';
import '../models/message_model.dart';
import 'message_timestamp.dart';
class MessageBubbleWidget extends StatelessWidget {
const MessageBubbleWidget({
Key? key,
required this.chatMessage,
required this.isMe,
}) : super(key: key);
final MessageModel chatMessage;
final bool isMe;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment:
isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(10),
margin: isMe
? const EdgeInsets.only(right: 10)
: const EdgeInsets.only(left: 10),
width: 200,
decoration: BoxDecoration(
color: isMe ? Colors.green : Colors.black12,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
chatMessage.author,
style: const TextStyle(fontSize: 13, color: Colors.white),
),
const SizedBox(height: 5),
Text(
chatMessage.message,
style: const TextStyle(fontSize: 16, color: Colors.white),
),
],
),
),
],
),
MessageTimestampWidget(timestamp: chatMessage.timestamp),
],
);
}
}
Precisamos criar agora o input onde o usuário digitará a mensagem a ser enviada, no diretório lib/widgets, iremos criar o arquivo chamado input_message.dart.
Note que estaremos recebendo por argumento o controller do input e o método responsável pelo envio da mensagem.
import 'package:flutter/material.dart';
class InputMessageWidget extends StatelessWidget {
const InputMessageWidget({
Key? key,
required this.messageEditingController,
required this.handleSubmit,
}) : super(key: key);
final TextEditingController messageEditingController;
final Function(String message) handleSubmit;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(8.0),
child: SizedBox(
width: double.infinity,
height: 60,
child: Row(
children: [
Flexible(
child: TextField(
keyboardType: TextInputType.text,
textCapitalization: TextCapitalization.sentences,
controller: messageEditingController,
cursorColor: const Color(0xff9b84ec),
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.never,
contentPadding: EdgeInsets.all(20.0),
filled: true,
fillColor: Color(0xff2f3136),
labelText: 'Message',
hintStyle: TextStyle(color: Colors.white),
labelStyle: TextStyle(color: Colors.white),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.black26),
borderRadius: BorderRadius.all(Radius.circular(60.0)),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Color(0xff9b84ec), width: 1),
borderRadius: BorderRadius.all(Radius.circular(60.0)),
),
border: OutlineInputBorder(
borderSide: BorderSide(color: Colors.green, width: 5),
borderRadius: BorderRadius.all(Radius.circular(60.0)),
gapPadding: 8.0,
),
),
)),
Container(
margin: const EdgeInsets.only(left: 4),
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(30),
),
child: IconButton(
onPressed: () => handleSubmit(messageEditingController.text),
icon: const Icon(Icons.send_rounded),
color: Colors.white,
),
),
],
),
),
);
}
}
Com os nossos widgets já escritos, vamos voltar no arquivo chat_screen.dart, terminar de montar a tela e importar os novos widgets.
Para que possamos atualizar as mensagens em tempo real, vamos usar o StreamBuilder, explicarei um pouco mais sobre…
StreamBuilder é um widget que converte um fluxo de objetos em widgets. De forma simples, ele escuta o stream e se constrói em cada novo evento emitido. Os eventos emitidos determinam se o fluxo de dados conseguiu ou não se conectar, se está em espera, se terminou ou se iniciou mas não terminou.
Nosso body vai ficar da seguinte forma:
Stack(
children: <Widget>[
Column(
children: <Widget>[
Flexible(
child: StreamBuilder<QuerySnapshot>(
stream: chatProvider.getMessageList(),
builder: (BuildContext context,
AsyncSnapshot<QuerySnapshot> snapshot) {
if (snapshot.hasData) {
messageList = snapshot.data!.docs;
if (messageList.isNotEmpty) {
return ListView.builder(
padding: const EdgeInsets.all(10),
itemCount: messageList.length,
reverse: true,
controller: scrollController,
itemBuilder: (context, index) =>
_buildItem(index, messageList[index]));
} else {
return const Center(
child: Text('Sem mensagens',
style: TextStyle(
color: Colors.white,
fontSize: 20,
)),
);
}
} else {
return const Center(
child: CircularProgressIndicator(
color: Colors.blue,
),
);
}
},
),
),
InputMessageWidget(
messageEditingController: messageEditingController,
handleSubmit: sendMessage,
),
],
)
],
),
Estamos usando o widget Stack para que possamos empilhar nosso input e o restante das mensagens. Também, fazemos verificações para saber se as mensagens estão carregando e se tem ou não mensagens para que assim o estado da tela mude. Se houver mensagens, obviamente vai mostrá-las, se ainda estiver carregando então mostre um loading e se não possuir mensagem alguma, mostre um aviso que está sem mensagem.
O resultado do chat_screen.dart, será:
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../models/message_model.dart';
import '../providers/chat_provider.dart';
import '../widgets/input_message.dart';
import '../widgets/message_bubble.dart';
class ChatScreen extends StatefulWidget {
const ChatScreen({Key? key}) : super(key: key);
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final ChatProvider chatProvider =
ChatProvider(firebaseFirestore: FirebaseFirestore.instance);
final TextEditingController messageEditingController =
TextEditingController();
List<QueryDocumentSnapshot> messageList = [];
final ScrollController scrollController = ScrollController();
currentUser(context) => ModalRoute.of(context)?.settings.arguments as String;
void sendMessage(String message) {
if (message.isNotEmpty) {
messageEditingController.clear();
chatProvider.sendMessage(message.trim(), currentUser(context));
scrollController.animateTo(0,
duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0XFF36393f),
appBar: AppBar(
centerTitle: true,
backgroundColor: const Color(0XFF23272a),
elevation: 1,
title: const Text('My.chat', style: TextStyle(fontSize: 16)),
),
body: Stack(
children: <Widget>[
Column(
children: <Widget>[
Flexible(
child: StreamBuilder<QuerySnapshot>(
stream: chatProvider.getMessageList(),
builder: (BuildContext context,
AsyncSnapshot<QuerySnapshot> snapshot) {
if (snapshot.hasData) {
messageList = snapshot.data!.docs;
if (messageList.isNotEmpty) {
return ListView.builder(
padding: const EdgeInsets.all(10),
itemCount: messageList.length,
reverse: true,
controller: scrollController,
itemBuilder: (context, index) =>
_buildItem(index, messageList[index]));
} else {
return const Center(
child: Text('Sem mensagens...',
style: TextStyle(
color: Colors.white,
fontSize: 20,
)),
);
}
} else {
return const Center(
child: CircularProgressIndicator(
color: Colors.blue,
),
);
}
},
),
),
InputMessageWidget(
messageEditingController: messageEditingController,
handleSubmit: sendMessage,
),
],
)
],
),
);
}
_buildItem(int index, DocumentSnapshot? documentSnapshot) {
if (documentSnapshot != null) {
final chatMessage = MessageModel.fromDocument(documentSnapshot);
final isMe = chatMessage.author == currentUser(context);
return MessageBubbleWidget(chatMessage: chatMessage, isMe: isMe);
}
}
}
Para finalizarmos, precisamos criar as rotas das nossas páginas no arquivo main.dart.
No widget MaterialApp, vamos informar nossas duas rotas no argumento routes e também vamos informar nossa rota inicial em initialRoute, que será “/”.
Nossa classe MyApp ficará assim:
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'My.chat',
theme: ThemeData(
primarySwatch: Colors.blue,
),
initialRoute: '/',
routes: {
'/': (context) => EnterRoomScreen(),
'/chat': (context) => const ChatScreen(),
},
);
}
}
O resultado do arquivo main.dart, será:
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'screens/chat_screen.dart';
import 'screens/enter_room_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'My.chat',
theme: ThemeData(
primarySwatch: Colors.blue,
),
initialRoute: '/',
routes: {
'/': (context) => EnterRoomScreen(),
'/chat': (context) => const ChatScreen(),
},
);
}
}
Rode o projeto e veja o resultado, nosso app parecerá com isto:
Concluindo a criação de um chat usando Flutter e Firebase
O Firebase é uma ótima ferramenta quando precisamos fazer algo rápido e fácil, sem que para isso seja necessário criar todo um projeto backend que levaria algumas horas ou quem sabe dias.
Quando unimos o Flutter e Firebase, conseguimos construir boas ferramentas de forma eficiente e gratuita (até um certo ponto). À medida que sua aplicação cresce, o Firebase começa a cobrar pelas requisições, então recomendo dar uma olhadinha nos preços caso queira começar um projeto grande com ele.
Se você não sabe o que é um widget e como funciona, recomendo dar uma olhadinha neste outro artigo aqui no blog da Taller.