Building a chat app is one of the best ways to learn real-world app development in Flutter. In this blog, we’ll walk through how to build a Flutter Chat App with Firebase that supports real-time messaging, friend requests, video/audio calls, and many more.
Key Features of the Chat App
Here’s what our chat app will cover:
✅ Real-Time Messaging
✅ Online & Typing Indicators
✅ Last Seen & Read/Delivered Receipts
✅ Friend Requests (Accept/Reject before chat)
✅ Unfriend/Block option
✅ Unread Count
✅ Send Emojis, Images, and Files
✅ Video & Audio Calls (via ZegoCloud)
✅ Change User Profile
✅ Display/Search All Users
✅ Google + Email/Password Authentication
✅ Online/Offline Status
✅ Light/Dark Theme
✅ Auto-scroll to bottom on new messages
✅ Responsive for both Android & iOS
✅ Proper error handling & smooth real-time refresh
We built this app using Flutter as the framework and Firebase for the backend and database. All features are integrated in Flutter, while ZegoCloud powers video and audio calls. Now, let’s get started.
Let’s get started by adding the package that will be required in our project.
dependencies: flutter: sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 firebase_core: ^4.0.0 firebase_auth: ^6.0.1 flutter_riverpod: ^2.6.1 google_sign_in: ^7.1.1 cloud_firestore: ^6.0.0 cherry_toast: ^1.13.0 intl: ^0.20.2 uuid: ^4.5.1 firebase_storage: ^13.0.1 image_picker: ^1.2.0 zego_uikit_prebuilt_call: ^4.18.2 permission_handler: ^12.0.1 zego_uikit_signaling_plugin: ^2.8.16 zego_uikit: ^2.28.26
Run flutter pub get
to install these packages.
Project Setup
Before writing any code, create a new Flutter project or continue with an existing project with the latest update of Flutter, and integrate Firebase with the Flutter project.
If you face any problem during Firebase setup, then watch this tutorial.
After the Firebase setup is completed, we continue with the project, starting with authentication.
Firebase Email Password Authentication Flutter
Implement functions to allow users to create accounts and sign in using email and password:(auth_model.dart)
class AuthFormState { final String name; final String email; final String password; final String? nameError; final String? emailError; final String? passwordError; final bool isLoading; final bool isPasswordHidden; AuthFormState({ this.name = '', this.email = '', this.password = '', this.nameError, this.emailError, this.passwordError, this.isLoading = false, this.isPasswordHidden = true, }); bool get isFormValid => emailError == null && passwordError == null && (name.isEmpty || nameError == null); AuthFormState copyWith({ String? name, String? email, String? password, String? nameError, String? emailError, String? passwordError, bool? isLoading, bool? isPasswordHidden, }) { return AuthFormState( name: name ?? this.name, email: email ?? this.email, password: password ?? this.password, nameError: nameError, emailError: emailError, passwordError: passwordError, isLoading: isLoading ?? this.isLoading, isPasswordHidden: isPasswordHidden ?? this.isPasswordHidden, ); } }
auth_service.dart
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class AuthMethod { final FirebaseFirestore _firestore = FirebaseFirestore.instance; final FirebaseAuth _auth = FirebaseAuth.instance; Future<String> signUpUser({ required String email, required String password, required String name, }) async { try { if (email.isEmpty || password.isEmpty || name.isEmpty) { return "Please enter all fields"; } UserCredential cred = await _auth.createUserWithEmailAndPassword( email: email, password: password, ); // Update user profile await cred.user!.updateDisplayName(name); // FIXED: Store user data with consistent field names await _firestore.collection("users").doc(cred.user!.uid).set({ "uid": cred.user!.uid, "name": name, "email": email, "photoURL": null, "isOnline": false, // Set to true when user signs up "provider": "email", 'lastSeen': FieldValue.serverTimestamp(), "createdAt": FieldValue.serverTimestamp(), }); return "success"; } catch (e) { return e.toString(); } } // Login with online status update Future<String> loginUser({ required String email, required String password, }) async { try { if (email.isEmpty || password.isEmpty) { return "Please enter all fields"; } await _auth.signInWithEmailAndPassword(email: email, password: password); // Update online status after login if (_auth.currentUser != null) { await _firestore.collection('users').doc(_auth.currentUser!.uid).update( {'isOnline': true, 'lastSeen': FieldValue.serverTimestamp()}, ); } return "success"; } catch (e) { return e.toString(); } } //Logout with online status update Future<void> signOut() async { if (_auth.currentUser != null) { // Set offline before signing out await _firestore.collection('users').doc(_auth.currentUser!.uid).update({ 'isOnline': false, 'lastSeen': FieldValue.serverTimestamp(), }); } await _auth.signOut(); } } final authMethodProvider = Provider<AuthMethod>((ref) { return AuthMethod(); });
auth_providder.dart
import 'package:flutter_firebase_chat_app/auth/model/auth_model.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class AuthFormNotifier extends StateNotifier<AuthFormState> { AuthFormNotifier() : super(AuthFormState()); void togglePasswordVisibility() { state = state.copyWith(isPasswordHidden: !state.isPasswordHidden); } void updateName(String name) { String? nameError; if (name.isNotEmpty && name.length < 6) { nameError = "Provide your full name"; } state = state.copyWith(name: name, nameError: nameError); } void updateEmail(String email) { String? emailError; if (email.isNotEmpty && !RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(email)) { emailError = 'Enter a valid email'; } state = state.copyWith(email: email, emailError: emailError); } void updatePassword(String password) { String? passwordError; if (password.isNotEmpty && password.length < 6) { passwordError = 'Password must be at least 6 characters'; } state = state.copyWith(password: password, passwordError: passwordError); } void setLoading(bool isLoading) { state = state.copyWith(isLoading: isLoading); } } final authFormProvider = StateNotifierProvider<AuthFormNotifier, AuthFormState>( (ref) { return AuthFormNotifier(); }, );
After creating an authentication service and a riverpod provider. Now we will implement it in the signup and login screens
signup_screen.dart
import 'package:flutter/material.dart'; import 'package:flutter_firebase_chat_app/auth/screen/user_login_screen.dart'; import 'package:flutter_firebase_chat_app/auth/service/auth_provider.dart'; import 'package:flutter_firebase_chat_app/auth/service/auth_service.dart'; import 'package:flutter_firebase_chat_app/core/utils/utils.dart'; import 'package:flutter_firebase_chat_app/my_button.dart'; import 'package:flutter_firebase_chat_app/route.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class SignupScreen extends ConsumerWidget { const SignupScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final formState = ref.watch(authFormProvider); final formNotifer = ref.read(authFormProvider.notifier); final authMethod = ref.read(authMethodProvider); void sigup() async { formNotifer.setLoading(true); final res = await authMethod.signUpUser( email: formState.email, password: formState.password, name: formState.name, ); formNotifer.setLoading(false); if (res == "success" && context.mounted) { NavigationHelper.pushReplacement(context, UserLoginScreen()); showAppSnackbar( context: context, type: SnackbarType.success, description: "Sinup Up Successful. Now turn to login", ); } else { if (context.mounted) { showAppSnackbar( context: context, type: SnackbarType.error, description: res, ); } } } double height = MediaQuery.of(context).size.height; return Scaffold( backgroundColor: Colors.white, body: SafeArea( child: ListView( children: [ Container( height: height / 2.4, width: double.maxFinite, decoration: BoxDecoration(), child: Image.asset("assets/77881.jpg", fit: BoxFit.cover), ), SizedBox(height: 20), Padding( padding: EdgeInsets.all(15), child: Column( children: [ TextField( autocorrect: false, onChanged: (value) => formNotifer.updateName(value), keyboardType: TextInputType.text, decoration: InputDecoration( prefixIcon: Icon(Icons.person), labelText: "Enter your name", border: OutlineInputBorder(), contentPadding: EdgeInsets.all(15), errorText: formState.nameError, ), ), SizedBox(height: 15), TextField( autocorrect: false, onChanged: (value) => formNotifer.updateEmail(value), keyboardType: TextInputType.emailAddress, decoration: InputDecoration( prefixIcon: Icon(Icons.email), labelText: "Enter your email", border: OutlineInputBorder(), contentPadding: EdgeInsets.all(15), errorText: formState.emailError, ), ), SizedBox(height: 15), TextField( autocorrect: false, onChanged: (value) => formNotifer.updatePassword(value), keyboardType: TextInputType.visiblePassword, obscureText: formState.isPasswordHidden, decoration: InputDecoration( prefixIcon: Icon(Icons.lock), labelText: "Enter your password", border: OutlineInputBorder(), contentPadding: EdgeInsets.all(15), errorText: formState.passwordError, suffixIcon: IconButton( onPressed: () => formNotifer.togglePasswordVisibility(), icon: Icon( formState.isPasswordHidden ? Icons.visibility_off : Icons.visibility, ), ), ), ), SizedBox(height: 20), formState.isLoading ? Center(child: CircularProgressIndicator()) : MyButton( onTab: formState.isFormValid ? sigup : null, buttonText: "Sign Up", ), SizedBox(height: 20), Row( children: [ Spacer(), Text("Already have an account?"), GestureDetector( onTap: () { NavigationHelper.push(context, UserLoginScreen()); }, child: Text( "Login", style: TextStyle(fontWeight: FontWeight.bold), ), ), ], ), ], ), ), ], ), ), ); } }
login_screen.dart
// ignore_for_file: use_build_context_synchronously import 'package:flutter/material.dart'; import 'package:flutter_firebase_chat_app/auth/screen/google_login_screen.dart'; import 'package:flutter_firebase_chat_app/auth/screen/signup_screen.dart'; import 'package:flutter_firebase_chat_app/auth/service/auth_provider.dart'; import 'package:flutter_firebase_chat_app/auth/service/auth_service.dart'; import 'package:flutter_firebase_chat_app/chat/screens/app_home_screen.dart'; import 'package:flutter_firebase_chat_app/core/utils/utils.dart'; import 'package:flutter_firebase_chat_app/my_button.dart'; import 'package:flutter_firebase_chat_app/route.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class UserLoginScreen extends ConsumerWidget { const UserLoginScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { double height = MediaQuery.of(context).size.height; final formState = ref.watch(authFormProvider); final formNotifer = ref.read(authFormProvider.notifier); final authMethod = ref.read(authMethodProvider); void login() async { formNotifer.setLoading(true); final res = await authMethod.loginUser( email: formState.email, password: formState.password, ); formNotifer.setLoading(false); if (res == "success") { NavigationHelper.pushReplacement(context, MainHomeScreen()); // mySnackBar(message: "Successful Login.", context: context); showAppSnackbar( context: context, type: SnackbarType.success, description: "Successful Login", ); } else { showAppSnackbar( context: context, type: SnackbarType.error, description: res, ); } } return Scaffold( backgroundColor: Colors.white, body: SingleChildScrollView( child: Column( children: [ SizedBox( height: height / 2.1, width: double.maxFinite, child: Image.asset("assets/2752392.jpg", fit: BoxFit.cover), ), Padding( padding: EdgeInsets.all(15), child: Column( children: [ TextField( autocorrect: false, onChanged: (value) => formNotifer.updateEmail(value), keyboardType: TextInputType.emailAddress, decoration: InputDecoration( prefixIcon: Icon(Icons.email), labelText: "Enter your email", border: OutlineInputBorder(), contentPadding: EdgeInsets.all(15), errorText: formState.emailError, ), ), SizedBox(height: 15), TextField( autocorrect: false, onChanged: (value) => formNotifer.updatePassword(value), keyboardType: TextInputType.visiblePassword, obscureText: formState.isPasswordHidden, decoration: InputDecoration( prefixIcon: Icon(Icons.lock), labelText: "Enter your password", border: OutlineInputBorder(), contentPadding: EdgeInsets.all(15), errorText: formState.passwordError, suffixIcon: IconButton( onPressed: () => formNotifer.togglePasswordVisibility(), icon: Icon( formState.isPasswordHidden ? Icons.visibility_off : Icons.visibility, ), ), ), ), SizedBox(height: 20), formState.isLoading ? Center(child: CircularProgressIndicator()) : MyButton( onTab: formState.isFormValid ? login : null, buttonText: "Login", ), SizedBox(height: 20), Row( children: [ Expanded( child: Container(height: 1, color: Colors.black26), ), Text(" or "), Expanded( child: Container(height: 1, color: Colors.black26), ), ], ), SizedBox(height: 15), // for google auth GoogleLoginScreen(), SizedBox(height: 15), Row( children: [ Spacer(), Text("Don't have an account? "), GestureDetector( onTap: () { NavigationHelper.push(context, SignupScreen()); }, child: Text( "SignUp", style: TextStyle(fontWeight: FontWeight.bold), ), ), ], ), ], ), ), ], ), ), ); } }
Finally, Email-password authentication is now complete. I’ve provided you with the full code for this feature. If you face any issues, you can also watch the separate tutorial video I created on email-password authentication.
Next, we’ve also added Google login as an authentication method in our project. To learn the complete process with the latest updates(7.1.1), you can check out the separate blog article and YouTube video on my channel and website, where I’ve also shared the source code.
ZEGOCLOUD Setup in Flutter
After completing authentication, we’ll continue with the remaining features of our Flutter chat app with Firebase. One of the main features is the ability to make audio and video calls. If you’re a beginner, setting up ZegoCloud for Quick Start (with call invitation) can be challenging. To make it easier, I’ll guide you through a step-by-step process to set up the ZegoCloud call invitation feature in a Flutter app.
- For Android: Add ZegoUIKitPrebuiltCall as dependencies
Run the following code in your project root directory :flutter pub add zego_uikit_prebuilt_call
- Import the SDK: Now, in your Dart code, import the prebuilt Call Kit SDK.
import 'package:zego_uiki/zego_uiki.dart'; import 'package:zego_uikit_prebuilt_call/zego_uikit_prebuilt_call.dart';
- Enter your project’s root directory, open the
settings.gradle
file to add the jitpack todependencyResolutionManagement
>repositories
like this:
repositories { google() mavenCentral() gradlePluginPortal() maven { url = uri("https://maven.zego.im") } // ✅ add Kotlin DSL maven { url = uri("https://www.jitpack.io") } // ✅ add Kotlin DSL } // add this two line in your settings.gradle.kts
- Modify your app-level
build.gradle
file by adding this:dependencies { implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version") }
- change the minSdkVersion and compileSdkVersion
minSdkVersion at least 26 and compileSdkVersion 36 . - Open the file
your_project/app/src/main/AndroidManifest.xml
, and add the following code:<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <!--for bring app to foreground from background in Android 10 --> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
- In your project’s ‘your_project > android > app’ folder, create a ‘proguard-rules.pro’ file with the following content as shown below
-keep class **.zego.** { *; }
- Add the following config code to the ‘release’ part of the ‘your_project/android/app/build.gradle’ file
proguardFiles( getDefaultProguardFile("proguard-android.txt"), file("proguard-rules.pro") )
- Add the latest Kotlin version in gradle.properties
kotlin_version=2.1.0
- For IOS: Open the ‘your_project/ios/Podfile’ file, and add the following to the ‘post_install do |installer|’ part
# Start of the permission_handler configuration target.build_configurations.each do |config| config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ '$(inherited)', 'PERMISSION_CAMERA=1', 'PERMISSION_MICROPHONE=1', ] end # End of the permission_handler configuration
- Open the ‘your_project/ios/Runner/Info.plist’ file, and add the following to the ‘dict’ part
<key>NSCameraUsageDescription</key> <string>We require camera access to connect</string> <key>NSMicrophoneUsageDescription</key> <string>We require microphone access to connect</string>
At the same time, I’ll provide a common toast message that we’ll use throughout the app — one for errors and another for success. For this, I’ve used the cherry_toast package.
import 'package:cherry_toast/cherry_toast.dart'; import 'package:cherry_toast/resources/arrays.dart'; import 'package:flutter/material.dart'; enum SnackbarType { success, error } void showAppSnackbar({ required BuildContext context, required SnackbarType type, required String description, }) { switch (type) { case SnackbarType.success: CherryToast.success( toastDuration: Duration(milliseconds: 2500), height: 70, toastPosition: Position.top, shadowColor: Colors.white, animationType: AnimationType.fromTop, displayCloseButton: false, backgroundColor: Colors.green.withAlpha(40), description: Text( description, style: const TextStyle(color: Colors.green), ), title: const Text( "Successful", style: TextStyle(color: Colors.green, fontWeight: FontWeight.bold), ), ).show(context); break; case SnackbarType.error: CherryToast.error( toastDuration: Duration(milliseconds: 2500), height: 70, toastPosition: Position.top, shadowColor: Colors.white, animationType: AnimationType.fromTop, displayCloseButton: false, backgroundColor: Colors.red.withAlpha(40), description: Text( description, style: const TextStyle(color: Colors.red), ), title: const Text( "Fail", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold), ), ).show(context); break; } }
Finally, we have completed all the setup and starter code to begin the project. For the full step-by-step process, you can follow our YouTube video.