Create a simple Flutter chat app

Let us create a simple Flutter chat app with Dart that uses Firebase to store the chat messages. It is going to be a super simple application. The user shall be able to open the application, set a nickname, choose a chat room, enter it, and send messages to anyone there. As simple as it gets!

SimpleChat

Try the app out on Google Play if you want.

First off all, install Flutter if you haven’t, and create a new project in Visual Code by clicking CTRL+SHIFT+P to get the command field and then select Flutter: New Application Project in your folder where you keep your projects, I will name the project simplechatapp.

I usually create my Android emulators through Android Studio at the AVD manager so do that if you haven’t created one. If you got an emulator you can start it from Visual code, with Flutter: Launch Emulator. Let us now try the app so that it is working by running flutter run in the terminal.

Let us change the text default “Flutter Demo Home Page” to “SimpleChat” in main.dart then click R in the terminal to hot reload. While we are at it let us clear all the clutter with comments and the default created code to make it easier to work. Also delete the code for MyHomePage.

You can also run your application in your web browser (Chrome) by having Dart DevTools installed and clicking F5 to start debugging. However, there is a lot of things that is not supported yet for the web sadly so you might stumble upon the “dart:method is not supported on dart4web” exception/error so for this project we are going to stick with Android, although it shouldn’t be a problem debugging and testing with the web for now.

Okay so we are ready to do things! We have flutter all ready to go and with hot reload we can use to see our changes fast. Let us create a new stateless widget by typing stl and call it RoomsListPage and in it add a List variable along with a ListView.builder like this:

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'FileIdea Chat',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: Material(child: RoomsListPage()),
    );
  }
}

class RoomsListPage extends StatelessWidget {
  final List<String> items =
      List<String>.generate(2, (index) => "Chat Room $index");

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (context, index) {
        return new ListTile(
          title: new Text('${items[index]}'),
          contentPadding: EdgeInsets.symmetric(horizontal: 26.0),
        );
      },
    );
  }
}

We now have a List that holds two items in the type of String. With the list we can work with our own defined object instead to make things more manageable so let us create a new class ChatRoom inside a new folder we call models under the lib folder.

Okay we’ve ChatRoom.dart, now let us set some properties for it. Keeping things simple we’ll just have an ID and Name.

class ChatRoom {
  int id;
  String name;

  ChatRoom(id) {
    this.id = id;
    this.name = "Chat room " + (id + 1).toString();
  }
}

We still have just Chat Room 1 and 2, but as we are using a constructor we can increase it with one so that we have chatroom starting from one and above. We’ll update the object initialization inside RoomsListPage so it is like this:

class RoomsListPage extends StatelessWidget {
  RoomsListPage({Key key}) : super(key: key);
  final List<ChatRoom> items =
      List<ChatRoom>.generate(2, (index) => new ChatRoom(index));

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (context, index) {
        return new ListTile(
          title: new Text('${items[index].name}'),
          contentPadding: EdgeInsets.symmetric(horizontal: 26.0),
        );
      },
    );
  }
}

Much cleaner when we have classes we are working with.

Add a navigational menu

Take a look at how a navigational menu can be added in Flutter here. We are actually going to take some inspiration and take the code for the navigational menu (without the content) and it into our application right here.

We’ll add the package reference inside the pubspec.yaml first

name: simplechatapp
description: A new Flutter project.

publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ">=2.12.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  bottom_navy_bar: ^6.0.0

  cupertino_icons: ^1.0.2
  gallery_view: ^0.0.4

dev_dependencies:
  flutter_test:
    sdk: flutter
  pedantic: ^1.10.0

flutter:

  uses-material-design: true

Then we add the menu from the Creating a simple Bottom Navigation Bar in Flutter post and also set RoomsListPage() inside the PageView so that is connected to the index 1. Now we have the chat rooms listed there under Chat Rooms.

Simple Chat now has a Flutter navigational menu

Add a textfield for the nickname

Let us now make it possible for the user to set the nickname under the first Profile page. It will be what we use as name when chatting in the chat rooms. Since we want this value to be set even when we close the app, let us utilize the shared preferences plugin for this! Basically what it does is saving the data into the users phone memory and thus we can access it when we start the app again. When the user reset the app data or uninstall the app then it will be destroyed. Add shared_preferences to your pubspec.yaml:

name: simplechatapp
description: A new Flutter project.

publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ">=2.12.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  bottom_navy_bar: ^6.0.0
  cupertino_icons: ^1.0.2
  shared_preferences: ^2.0.5

dev_dependencies:
  flutter_test:
    sdk: flutter
  pedantic: ^1.10.0

flutter:

  uses-material-design: true

Okay we now have the ability to save into the phone. Let us add a stateful widget with the usual stful named ProfileInfo. Let us stay with the simple style and just add a plain form field with a button and leave it at that. You can obviously style this much more pretty, and do code factoring etc but that I’ll leave up to you :). Let me copy-paste the whole thing and describe from there what we are doing.

import 'package:bottom_navy_bar/bottom_navy_bar.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:simplechatapp/models/ChatRoom.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'FileIdea Chat',
      home: MyHomePage(),
    );
  }
}

class ProfileInfo extends StatefulWidget {
  @override
  _ProfileInfoState createState() => _ProfileInfoState();
}

class _ProfileInfoState extends State<ProfileInfo> {
  final nicknameFieldController = TextEditingController();
  String nickname = "";
  @override
  void initState() {
    super.initState();

    SharedPreferences.getInstance().then((prefValue) => {
          setState(() {
            nickname = prefValue.getString('nickname') ?? "";
            nicknameFieldController.text = nickname;
          })
        });

    nicknameFieldController.addListener(() {
      setNickname();
    });
  }

  void saveNickname() async {
    final prefs = await SharedPreferences.getInstance();
    prefs.setString('nickname', nickname);
  }

  setNickname() async {
    setState(() {
      nickname = nicknameFieldController.text;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: <Widget>[
            Text("Nickname: $nickname"),
            TextField(
              enabled: true,
              controller: nicknameFieldController,
            ),
            Container(
                margin: const EdgeInsets.only(top: 20.0),
                child: ElevatedButton(
                  onPressed: () {
                    saveNickname();
                    final snackBar = SnackBar(
                      content: Text('Updated username!'),
                    );
                    ScaffoldMessenger.of(context).showSnackBar(snackBar);
                  },
                  child: Text('Update'),
                ))
          ],
        ),
      ),
    );
  }
}

class RoomsListPage extends StatelessWidget {
  RoomsListPage({Key? key}) : super(key: key);
  final List<ChatRoom> items =
      List<ChatRoom>.generate(2, (index) => new ChatRoom(index));

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (context, index) {
        return new ListTile(
          title: new Text('${items[index].name}'),
          contentPadding: EdgeInsets.symmetric(horizontal: 26.0),
        );
      },
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  late PageController _pageController;

  @override
  void initState() {
    super.initState();
    _pageController = PageController();
  }

  @override
  void dispose() {
    _pageController.dispose();
    super.dispose();
  }

  int _currentIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("FileIdea Chat")),
      body: SizedBox.expand(
        child: PageView(
          controller: _pageController,
          onPageChanged: (index) {
            setState(() => _currentIndex = index);
          },
          children: <Widget>[
            ProfileInfo(),
            RoomsListPage(),
          ],
        ),
      ),
      bottomNavigationBar: BottomNavyBar(
        selectedIndex: _currentIndex,
        showElevation: true,
        itemCornerRadius: 50,
        mainAxisAlignment: MainAxisAlignment.center,
        curve: Curves.easeIn,
        onItemSelected: (index) => {
          setState(() => _currentIndex = index),
          _pageController.jumpToPage(index)
        },
        items: <BottomNavyBarItem>[
          BottomNavyBarItem(
            icon: Icon(Icons.people),
            title: Text('Profile'),
            activeColor: Colors.purpleAccent,
            textAlign: TextAlign.center,
          ),
          BottomNavyBarItem(
            icon: Icon(Icons.message),
            title: Text(
              'Chat Rooms',
            ),
            activeColor: Colors.pink,
            textAlign: TextAlign.center,
          ),
        ],
      ),
    );
  }
}

Adding a TextEditingController with TextField that saves to SharedPreferences

I believe it is easier to just have all the code at once in case you wanna copy-paste the whole thing instead of chopping everything up, but explanation can be useful for sure, so let me go through everything in detail what we just did:

Using TextEditingController for a TextField

  final nicknameFieldController = TextEditingController();

With the TextEditingController object we can handle the value that is being inputted into the TextField Widget we also added. Since we defined the field as nicknameFieldController we will set the TextField controller property to nicknameFieldController to connect them.

initState with SharedPreferences

  @override
  void initState() {
    super.initState();

    SharedPreferences.getInstance().then((prefValue) => {
          setState(() {
            nickname = prefValue.getString('nickname') ?? "";
            nicknameFieldController.text = nickname;
          })
        });

    nicknameFieldController.addListener(() {
      setNickname();
    });
  }

We also have a initState() which is useful if we want to get the nickname we store in the SharedPreferences. What we need to remember is that we can not use the async keyword with initState which leads us to use the then method you see after SharedPreferences.GetInstance(). What we are doing here is that we run the GetInstance() which loads and parses the SharedPreferences for this app from disk then run the method GetString(“nickname”), and if we get NULL here, it will be an empty string because of the two question marks we have there. Two question marks will take whatever is on the right side if the value (the returned value from the method) is NULL.

We can also set the value of the text property of nicknameFieldController so that the TextField won’t be just empty from the start which we do here from the previous retrieved value of the nickname field.

After that we will finally set a listener which is run whenever TextField is changed, what we do then is running the setNickname() method. The name should be quite self explanatory.

Saving the nickname from the TextField

  void saveNickname() async {
    final prefs = await SharedPreferences.getInstance();
    prefs.setString('nickname', nickname);
  }

With our saveNickname() method we simply do the usual SharedPreferences getInstance() call and then we set the value for our nickname field which we do in the next method. This method is only called when we press the ElevatedButton.

  setNickname() async {
    setState(() {
      nickname = nicknameFieldController.text;
    });
  }

setNickname() is called anytime we change the TextField since we have added a listener to our nicknameFieldController, and when that happens we set the nickname field.

The Build function with a TextField, ElevatedButton and a snackBar

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: <Widget>[
            Text("Nickname: $nickname"),
            TextField(
              enabled: true,
              controller: nicknameFieldController,
            ),
            Container(
                margin: const EdgeInsets.only(top: 20.0),
                child: ElevatedButton(
                  onPressed: () {
                    saveNickname();
                    final snackBar = SnackBar(
                      content: Text('Updated username!'),
                    );
                    ScaffoldMessenger.of(context).showSnackBar(snackBar);
                  },
                  child: Text('Update'),
                ))
          ],
        ),
      ),
    );
  }

There is a couple of things going on here but the most important thing is that we have now added a TextField Widget which uses our nicknameFieldController for controller, and below our TextField we have a Container Widget that contains an ElevatedButton, and this button have a onPressed function which we use to call on the saveNickname() function. At the same time we also display a SnackBar. We define a variable in the function that is of the SnackBar type which we set the content to be a “Updated username!” Text which we then pass to the ScaffoldMessenger showSnackBar function.

Enter Chat room

It’s time to go back and do something with our chat rooms! Let us make it possible to click on a chat room and enter it, we will need a new stateful widget and take use of the MaterialPageRoute to pass parameters.

We will first create a stateful widget called RoomPage that has a int id field, and we also have a constructor that takes in a (required) int id as a parameter. This id is going to represent our room ID.

class RoomPage extends StatefulWidget {
  RoomPage({Key? key, required this.id}) : super(key: key);
  final int id;
  @override
  _RoomPageState createState() => _RoomPageState();
}

class _RoomPageState extends State<RoomPage> {
  @override
  Widget build(BuildContext context) {
    return Container(child: Text(widget.id.toString()));
  }
}

Update the RoomList class as well

class RoomsListPage extends StatelessWidget {
  RoomsListPage({Key? key}) : super(key: key);
  final List<ChatRoom> items =
      List<ChatRoom>.generate(2, (index) => new ChatRoom(index));

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (context, index) {
        return new ListTile(
          title: new Text('${items[index].name}'),
          onTap: () {
            Navigator.push(
              context,
              MaterialPageRoute(builder: (context) => RoomPage(id: index)),
            );
          },
          contentPadding: EdgeInsets.symmetric(horizontal: 26.0),
        );
      },
    );
  }
}

We are now utilizing the Navigator push function to transition into RoomPage, we also pass the room ID, or should I say index, from the list into the RoomPage constructor. Let us check out what we’ve done so far:

Showing the progress of the app

Great we can go into the room but nothing is there except it is showing what ID there is from the list. Let us load some messages into this!

Get messages into the chat room

There are tons of options our there to how to store data, but I like Firebase and it is pretty perfect to use Firestore in this case. Since Flutter is made by Google and they have Firebase made for mobile apps, so why not go Google all the way. Let us go to the firebase console and add a project, I call mine SimpleChat.

Viewing Cloud Firestore in Firebase

Go to Firestore and create a database, I’ll select test for the rules, we can always change this later. Since I am European I’ll go with European firestore location. Then we should have an empty database! Great, let us add the firebase package firebase_core for Flutter, all of that documentation is here. I’m going with the Null safety version here. We also need to add the cloud_firestore package.

Adding the package into the pubspec.yaml

name: simplechatapp
description: A new Flutter project.

publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ">=2.12.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  bottom_navy_bar: ^6.0.0
  cupertino_icons: ^1.0.2
  shared_preferences: ^2.0.5
  firebase_core: "^1.0.4"
  cloud_firestore: "^1.0.6"

dev_dependencies:
  flutter_test:
    sdk: flutter
  pedantic: ^1.10.0

flutter:

  uses-material-design: true

I’m doing this for Android, you can choose other platforms too from the documentation!

Open up android/build.gradle and add this under dependencies:

    classpath 'com.google.gms:google-services:4.3.3'

Then open up android/app/build.gradle and add this:

apply plugin: 'com.google.gms.google-services'

Then go to Project settings at Firebase under the configuration wheel icon and click on the Android icon and match it with your app information which you can find under simplechatapp\android\app\src\main\AndroidManifest.xml.

  • So inside Register app I’ll type in: com.fileidea.simpleChat (I actually changed mine from example to fileidea with this rename package) and SimpleChat as nickname and skip SHA-1.
  • Download the config file into your android/app folder

Great we got the configuration down, let’s add the code that actually initialises Firebase with our .json file.

Since we are getting synchronous data from Firestore we must work with Futures. Let us take the sample code from the documentation so we have our MyApp look like this instead:

import 'package:bottom_navy_bar/bottom_navy_bar.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:simplechatapp/models/ChatRoom.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  // Create the initialization Future outside of `build`:
  final Future<FirebaseApp> _initialization = Firebase.initializeApp();

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      // Initialize FlutterFire:
      future: _initialization,
      builder: (context, snapshot) {
        // Check for errors
        if (snapshot.hasError) {
          print(snapshot.toString());

          return MaterialApp(
              home: Container(
            color: Colors.white,
            child: Text("error"),
          ));
        }

        // Once complete, show your application
        if (snapshot.connectionState == ConnectionState.done) {
          return MaterialApp(home: MyHomePage());
        }

        // Otherwise, show something whilst waiting for initialization to complete
        return MaterialApp(
            home: Container(
          color: Colors.white,
          child: Text("Loading"),
        ));
      },
    );
  }
}

class ProfileInfo extends StatefulWidget {
  @override
  _ProfileInfoState createState() => _ProfileInfoState();
}

class _ProfileInfoState extends State<ProfileInfo> {
  final nicknameFieldController = TextEditingController();
  String nickname = "";
  @override
  void initState() {
    super.initState();

    SharedPreferences.getInstance().then((prefValue) => {
          setState(() {
            nickname = prefValue.getString('nickname') ?? "";
            nicknameFieldController.text = nickname;
          })
        });

    nicknameFieldController.addListener(() {
      setNickname();
    });
  }

  void saveNickname() async {
    final prefs = await SharedPreferences.getInstance();
    prefs.setString('nickname', nickname);
  }

  setNickname() async {
    setState(() {
      nickname = nicknameFieldController.text;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: <Widget>[
            Text("Nickname: $nickname"),
            TextField(
              enabled: true,
              controller: nicknameFieldController,
            ),
            Container(
                margin: const EdgeInsets.only(top: 20.0),
                child: ElevatedButton(
                  onPressed: () {
                    saveNickname();
                    final snackBar = SnackBar(
                      content: Text('Updated username!'),
                    );
                    ScaffoldMessenger.of(context).showSnackBar(snackBar);
                  },
                  child: Text('Update'),
                ))
          ],
        ),
      ),
    );
  }
}

class RoomPage extends StatefulWidget {
  RoomPage({Key? key, required this.id}) : super(key: key);
  final int id;
  @override
  _RoomPageState createState() => _RoomPageState();
}

class _RoomPageState extends State<RoomPage> {
  @override
  Widget build(BuildContext context) {
    return Container(child: Text(widget.id.toString()));
  }
}

class RoomsListPage extends StatelessWidget {
  RoomsListPage({Key? key}) : super(key: key);
  final List<ChatRoom> items =
      List<ChatRoom>.generate(2, (index) => new ChatRoom(index));

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (context, index) {
        return new ListTile(
          title: new Text('${items[index].name}'),
          onTap: () {
            Navigator.push(
              context,
              MaterialPageRoute(builder: (context) => RoomPage(id: index)),
            );
          },
          contentPadding: EdgeInsets.symmetric(horizontal: 26.0),
        );
      },
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  late PageController _pageController;

  @override
  void initState() {
    super.initState();
    _pageController = PageController();
  }

  @override
  void dispose() {
    _pageController.dispose();
    super.dispose();
  }

  int _currentIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("FileIdea Chat")),
      body: SizedBox.expand(
        child: PageView(
          controller: _pageController,
          onPageChanged: (index) {
            setState(() => _currentIndex = index);
          },
          children: <Widget>[
            ProfileInfo(),
            RoomsListPage(),
          ],
        ),
      ),
      bottomNavigationBar: BottomNavyBar(
        selectedIndex: _currentIndex,
        showElevation: true,
        itemCornerRadius: 50,
        mainAxisAlignment: MainAxisAlignment.center,
        curve: Curves.easeIn,
        onItemSelected: (index) => {
          setState(() => _currentIndex = index),
          _pageController.jumpToPage(index)
        },
        items: <BottomNavyBarItem>[
          BottomNavyBarItem(
            icon: Icon(Icons.people),
            title: Text('Profile'),
            activeColor: Colors.purpleAccent,
            textAlign: TextAlign.center,
          ),
          BottomNavyBarItem(
            icon: Icon(Icons.message),
            title: Text(
              'Chat Rooms',
            ),
            activeColor: Colors.pink,
            textAlign: TextAlign.center,
          ),
        ],
      ),
    );
  }
}

Now we are working with Futures, Asynchronous data, meaning, we will communicate with the Firebase Core API from our app and have a FutureBuilder which will be used when we get the data back. When the snapshot we get back has connectionState equals to ConnectionState.done then we can continue showing our normal MyHomePage Widget, otherwise print the error to the console with a blank white page. Also during load, show a Widget displaying the text Loading.

Let’s try it out.

flutter run

I got the following error:

BUILD FAILED in 55s
Running Gradle task ‘assembleDebug’…
Running Gradle task ‘assembleDebug’… Done 56.5s
[!] The shrinker may have failed to optimize the Java bytecode.
To disable the shrinker, pass the --no-shrink flag to this command.
To learn more, see: https://developer.android.com/studio/build/shrink-code
Exception: Gradle task assembleDebug failed with exit code 1

Solution was to go to your app build gradle file (at …\simplechatapp\android\app\build.gradle) and change minSdkversion from 16 to 21.

You shouldn’t get any errors and it the app is now running as it should (on Android)! Now let us get Firestore in there, again we have all the documentation here. Inside our our _RoomPageState, let us add a reference to Firestore like this (se code below), and remember to import the package so it appears at the top:

FirebaseFirestore firestore = FirebaseFirestore.instance;

Create a class and save it under your Models folder called ChatMessage.dart that we will use for messages:

class ChatMessage {
  String message = "";
  String type = "";
  DateTime date = new DateTime.now();

  ChatMessage(message, type) {
    this.message = message;
    this.type = type;
    date = DateTime.now();
  }
}

To keep parse time from Firestore we can use the intl package. So install it with the pub add command so it appears in our pubspec.yaml file.

flutter pub add intl

Then we need to add some UI, mainly a TextField, FloatingActionButton and a ListView, all inside a StreamBuilder. We can use the StreamBuilder because we can back a Stream from Firestore. I have taken inspiration and modified some of the code from freecodechamp article that explains how to make a UI for a chat app, so thanks to them for the widget layout! Okay carrying, this is the code for the list that is coming from Firestore.

Modifying the _RoomPageState class inside main.dart it now looks like this:

import 'package:bottom_navy_bar/bottom_navy_bar.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:simplechatapp/models/ChatRoom.dart';

import 'models/ChatMessage.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  // Create the initialization Future outside of `build`:
  final Future<FirebaseApp> _initialization = Firebase.initializeApp();

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      // Initialize FlutterFire:
      future: _initialization,
      builder: (context, snapshot) {
        // Check for errors
        if (snapshot.hasError) {
          return MaterialApp(
              home: Container(
            color: Colors.white,
            child: Text("error"),
          ));
        }

        // Once complete, show your application
        if (snapshot.connectionState == ConnectionState.done) {
          return MaterialApp(home: MyHomePage());
        }

        // Otherwise, show something whilst waiting for initialization to complete
        return MaterialApp(
            home: Container(
          color: Colors.white,
          child: Text("Loading"),
        ));
      },
    );
  }
}

class ProfileInfo extends StatefulWidget {
  @override
  _ProfileInfoState createState() => _ProfileInfoState();
}

class _ProfileInfoState extends State<ProfileInfo> {
  final nicknameFieldController = TextEditingController();
  String nickname = "";
  @override
  void initState() {
    super.initState();

    SharedPreferences.getInstance().then((prefValue) => {
          setState(() {
            nickname = prefValue.getString('nickname') ?? "";
            nicknameFieldController.text = nickname;
          })
        });

    nicknameFieldController.addListener(() {
      setNickname();
    });
  }

  void saveNickname() async {
    final prefs = await SharedPreferences.getInstance();
    prefs.setString('nickname', nickname);
  }

  setNickname() async {
    setState(() {
      nickname = nicknameFieldController.text;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: <Widget>[
            Text("Nickname: $nickname"),
            TextField(
              enabled: true,
              controller: nicknameFieldController,
            ),
            Container(
                margin: const EdgeInsets.only(top: 20.0),
                child: ElevatedButton(
                  onPressed: () {
                    saveNickname();
                    final snackBar = SnackBar(
                      content: Text('Updated username!'),
                    );
                    ScaffoldMessenger.of(context).showSnackBar(snackBar);
                  },
                  child: Text('Update'),
                ))
          ],
        ),
      ),
    );
  }
}

class RoomPage extends StatefulWidget {
  RoomPage({Key? key, required this.id}) : super(key: key);
  final int id;
  @override
  _RoomPageState createState() => _RoomPageState();
}

class _RoomPageState extends State<RoomPage> {
  late String message = "";
  late String nickname = "";
  late String roomname = "";

  FirebaseFirestore firestore = FirebaseFirestore.instance;
  final TextEditingController _chatMessageController = TextEditingController();

  @override
  void initState() {
    super.initState();

    SharedPreferences.getInstance().then((prefValue) => {
          setState(() {
            nickname = prefValue.getString('nickname') ?? "";
          })
        });
    roomname = "Chat room " + (widget.id + 1).toString();
  }

  setMessage() async {
    setState(() {
      message = this._chatMessageController.text;
    });
  }

  DateTime readTimeStamp(dynamic date) {
    Timestamp timestamp = date;
    return timestamp.toDate();
  }

  @override
  Widget build(BuildContext context) {
    TextEditingController messageController = TextEditingController();
    CollectionReference messagesCollection = FirebaseFirestore.instance
        .collection("chatrooms")
        .doc(widget.id.toString())
        .collection("messages");

    final snapStream = messagesCollection
        .orderBy('date', descending: true)
        .limit(100)
        .snapshots()
        .map((obj) => obj.docs
            .map((e) => new ChatMessage(
                e.data()['message'],
                e.data()['nickname'].toString(),
                readTimeStamp(e.data()['date'])))
            .toList());

    Future<void> addMessage() {
      return messagesCollection.add({
        'message': this.message,
        "nickname": this.nickname,
        "date": DateTime.now()
      }).then((value) => messageController.text = "");
    }

    return StreamBuilder<List<ChatMessage>>(
      stream: snapStream,
      builder: (BuildContext context, snapshot) {
        if (snapshot.hasError) {
          return Text('Something went wrong');
        }

        if (snapshot.connectionState == ConnectionState.waiting) {
          return DisplayLoading();
        }

        return Scaffold(
          appBar: AppBar(title: Text(this.roomname)),
          body: Stack(
            children: <Widget>[
              Align(
                child: ListView.builder(
                  itemCount: (snapshot.data as List<ChatMessage>).length,
                  shrinkWrap: true,
                  reverse: true,
                  physics: AlwaysScrollableScrollPhysics(),
                  padding: EdgeInsets.only(top: 10, bottom: 80),
                  itemBuilder: (context, index) {
                    final messageData =
                        (snapshot.data as List<ChatMessage>)[index];
                    final bool isMe = messageData.nickname == nickname;
                    final String messenger = messageData.nickname;
                    late String timeformat = DateFormat('yyyy-MM-dd – kk:mm')
                        .format(messageData.date);

                    late String message = messageData.message;

                    if (!isMe) {
                      message = messenger + ': ' + message;
                    }

                    return Container(
                      padding: EdgeInsets.only(
                          left: 14, right: 14, top: 10, bottom: 10),
                      child: Align(
                        alignment:
                            (!isMe ? Alignment.topLeft : Alignment.topRight),
                        child: Stack(children: [
                          GestureDetector(
                              onTap: () => {},
                              child: Container(
                                decoration: BoxDecoration(
                                  borderRadius: BorderRadius.circular(20),
                                  color: (!isMe
                                      ? Colors.grey.shade200
                                      : Colors.blue[400]),
                                ),
                                padding: EdgeInsets.all(16),
                                child: RichText(
                                  text: TextSpan(
                                    style: TextStyle(
                                        fontSize: 11,
                                        color: !isMe
                                            ? Colors.black87
                                            : Colors.white),
                                    text: '$timeformat\n',
                                    children: [
                                      TextSpan(
                                        style: TextStyle(
                                            fontWeight: FontWeight.bold,
                                            fontSize: 15,
                                            color: !isMe
                                                ? Colors.black87
                                                : Colors.white),
                                        text: message,
                                      ),
                                    ],
                                  ),
                                ),
                              ))
                        ]),
                      ),
                    );
                  },
                ),
              ),
              Align(
                alignment: Alignment.bottomLeft,
                child: Container(
                  padding: EdgeInsets.only(left: 10, bottom: 10, top: 10),
                  height: 60,
                  width: double.infinity,
                  color: Colors.white,
                  child: Row(
                    children: <Widget>[
                      SizedBox(
                        width: 15,
                      ),
                      Expanded(
                        child: TextField(
                          controller: messageController,
                          onChanged: (str) => {this.message = str},
                          decoration: InputDecoration(
                              hintText: "Write message...",
                              hintStyle: TextStyle(color: Colors.black54),
                              border: InputBorder.none),
                        ),
                      ),
                      SizedBox(
                        width: 15,
                      ),
                      FloatingActionButton(
                        onPressed: addMessage,
                        child: Icon(
                          Icons.send,
                          color: Colors.white,
                          size: 18,
                        ),
                        backgroundColor: Colors.blue,
                        elevation: 0,
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        );
      },
    );
  }
}

class DisplayLoading extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Material(
      child: Center(child: Text("Loading...")),
    );
  }
}

class RoomsListPage extends StatelessWidget {
  RoomsListPage({Key? key}) : super(key: key);
  final List<ChatRoom> items =
      List<ChatRoom>.generate(2, (index) => new ChatRoom(index));

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (context, index) {
        return new ListTile(
          title: new Text('${items[index].name}'),
          onTap: () {
            Navigator.push(
              context,
              MaterialPageRoute(builder: (context) => RoomPage(id: index)),
            );
          },
          contentPadding: EdgeInsets.symmetric(horizontal: 26.0),
        );
      },
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  late PageController _pageController;

  @override
  void initState() {
    super.initState();
    _pageController = PageController();
  }

  @override
  void dispose() {
    _pageController.dispose();
    super.dispose();
  }

  int _currentIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("Simple Chat")),
      body: SizedBox.expand(
        child: PageView(
          controller: _pageController,
          onPageChanged: (index) {
            setState(() => _currentIndex = index);
          },
          children: <Widget>[
            ProfileInfo(),
            RoomsListPage(),
          ],
        ),
      ),
      bottomNavigationBar: BottomNavyBar(
        selectedIndex: _currentIndex,
        showElevation: true,
        itemCornerRadius: 50,
        mainAxisAlignment: MainAxisAlignment.center,
        curve: Curves.easeIn,
        onItemSelected: (index) => {
          setState(() => _currentIndex = index),
          _pageController.jumpToPage(index)
        },
        items: <BottomNavyBarItem>[
          BottomNavyBarItem(
            icon: Icon(Icons.people),
            title: Text('Profile'),
            activeColor: Colors.purpleAccent,
            textAlign: TextAlign.center,
          ),
          BottomNavyBarItem(
            icon: Icon(Icons.message),
            title: Text(
              'Chat Rooms',
            ),
            activeColor: Colors.pink,
            textAlign: TextAlign.center,
          ),
        ],
      ),
    );
  }
}

Showing the complete finish app of a simpel chat

Let us go through the things we added.

TextEditingController for the TextField

  late String message = "";
  late String nickname = "";
  late String roomname = "";

  FirebaseFirestore firestore = FirebaseFirestore.instance;
  final TextEditingController _chatMessageController = TextEditingController();

Like stated in the title, we use the TextEditingController to have control over the TextField . That TextField is for the text chat input. With this controller set to the TextField we can easily access the text property to blank out the TextField when the user sent a message. We also define the FirebaseFirestore instance here so we can access all the Firestore collections and documents from our Firebase account.

initState

@override
  void initState() {
    super.initState();

    SharedPreferences.getInstance().then((prefValue) => {
          setState(() {
            nickname = prefValue.getString('nickname') ?? "";
          })
        });
    roomname = "Chat room " + (widget.id + 1).toString();
  }

In the initState() function we can set the nickname from the SharedPreferences (which the user sets in the HomePage(), we use this field when we send message. We also set the roomname here to display it in the AppBar.

readTimeStamp

  DateTime readTimeStamp(dynamic date) {
    Timestamp timestamp = date;
    return timestamp.toDate();
  }

We create this function to take in a timestamp from Firestore and convert it into a DateTime object which we later can use to format it into a String. You’d think Firestore handle your DateTime (using DateTime.now() gives you a DateTime object) as a Date of some sort, but what happens is that it will be stored as a Timestamp value in Firestore, thus we handle those values with this function.

Get Firestore data from collections

@override
  Widget build(BuildContext context) {
    TextEditingController messageController = TextEditingController();
    CollectionReference messagesCollection = FirebaseFirestore.instance
        .collection("chatrooms")
        .doc(widget.id.toString())
        .collection("messages");

    final snapStream = messagesCollection
        .orderBy('date', descending: true)
        .limit(100)
        .snapshots()
        .map((obj) => obj.docs
            .map((e) => new ChatMessage(
                e.data()['message'],
                e.data()['nickname'].toString(),
                readTimeStamp(e.data()['date'])))
            .toList());

    Future<void> addMessage() {
      return messagesCollection.add({
        'message': this.message,
        "nickname": this.nickname,
        "date": DateTime.now()
      }).then((value) => messageController.text = "");
    }

I would say that here is where the fun starts! We are now retrieving data from our Firestore collections inside the chatrooms document. We will first make a reference to or collection using our id Field (the room ID), then we will make another Field called snapStream. With the snapStream we will make a query on our previous reference to get messages in a descending order by the date property. We also have a map function so that we can go through the documents we retrieved and create ChatMessage objects from it. We could of course do it without mapping, but doing so gives us a lot of more control and structure, now we can use intellisense accessing our properties and so forth.

addMessage() is simply adding a json object to the messageCollection. This could be refined even more of course working with a ChatMessage object, but we don’t have all day… Then after this is done we will reset the text chat TextField to an empty String.

StreamBuilder

return StreamBuilder<List<ChatMessage>>(
      stream: snapStream,
      builder: (BuildContext context, snapshot) {
        if (snapshot.hasError) {
          return Text('Something went wrong');
        }

        if (snapshot.connectionState == ConnectionState.waiting) {
          return DisplayLoading();
        }

        return Scaffold(
          appBar: AppBar(title: Text(this.roomname)),
          body: Stack(
            children: <Widget>[
              Align(
                child: ListView.builder(
                  itemCount: (snapshot.data as List<ChatMessage>).length,
                  shrinkWrap: true,
                  reverse: true,
                  physics: AlwaysScrollableScrollPhysics(),
                  padding: EdgeInsets.only(top: 10, bottom: 80),
                  itemBuilder: (context, index) {
                    final messageData =
                        (snapshot.data as List<ChatMessage>)[index];
                    final bool isMe = messageData.nickname == nickname;
                    final String messenger = messageData.nickname;
                    late String timeformat = DateFormat('yyyy-MM-dd – kk:mm')
                        .format(messageData.date);

                    late String message = messageData.message;

                    if (!isMe) {
                      message = messenger + ': ' + message;
                    }

Then we will return the things which are a lot of UI you see, but the important part is that we use s StreamBuilder which can take a Stream (in our case our snapshots() which will retrieve realtime data from Firestore) and display the data in a ListView if it does crash, then it will display an error, or if it’s loading we will show a Loading Widget. While inside the itemBuilder, we can access our items this we (snapshot.data as List<ChatMessage>)[index]. Then we can access the properties easily. Notice how we use true on the reverse property, this is a little trick that makes the ListView always scrolled to the bottom, because we have our items coming in from the snapshot in reverse order they will be correctly displayed. Then after this code snippet there is UI so I’d say there is no need to explain anything more here, we are simply just setting some more variables based on the data we have. Worth pointing out maybe is that we are setting an isMe field that checks if the current user is same as the message item, and if so, use blue color for the message showing that it is our message.

Feel free to adjust

Feel free to copy and alter and make improvements! There is tons of things left to make it better, like making a service to handle all the requests, better security in firestore (adding rules), authentication, nicer UI, make more use of the models, set the room names to something more creative, refactoring. The list is long, I’d love to do them all, but one has to eat. Hope you enjoyed this tutorial on how to create a simple chat app in Flutter!

0 0 votes
Article rating
Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments