19
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Flutter chat app

Last updated at Posted at 2018-06-08

#Flutter
Flutter is a cross-platform app development framework and it works on Android, iOS
Flutter is completely written in Dart. But worry not, Dart is very easy to learn if you’ve worked with Java and Javascript

#Goal
The goal of this post is to show you how to build an app with a Login screen and chat real-time.

Our app contains two screens:

Login Screen (Includes text fields for username and password, and a button for login)

Chat Screen (Display messages history and message real-time)

##Implement App side
Firstly, I hope you have already setup Flutter in your favorite IDE. I will be using Android Studio here. If you haven’t set it up yet then please click here

edit pubspec.yaml to include the necessary plugins.

/home/nguyenthanhluan/ruby/flutter/chat_app/chat/pubspec.yaml

name: chat
description: A new Flutter 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: ^0.1.0
  uuid: 1.0.0
  sqflite: ^0.7.1
  path_provider: ^0.3.1
  firebase_messaging: ^1.0.1
  flutter_app_badger: ^1.0.1
  # flutter_localstorage:
  #   git: git://github.com/lakexyde/flutter_localstorage.git
dev_dependencies:
  flutter_test:
    sdk: flutter


# For information on the generic Dart part of this file, see the
# following page: https://www.dartlang.org/tools/pub/pubspec

# The following section is specific to Flutter.
flutter:

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true

###Navigation and Routes
Our app has only two screens, but we will use the Routing feature built into Flutter to navigate to login screen or home screen depending on the login state of the user.

/home/nguyenthanhluan/ruby/flutter/chat_app/chat/lib/src/routes.dart

import 'package:flutter/material.dart';
import 'package:chat/src/widgets/chat/chat_screen.dart';
import 'package:chat/src/widgets/login/login_screen.dart';

final routes = {
  "/login": (BuildContext context) => new LoginScreen(),
  "/chat": (BuildContext context) => new ChatScreen(),
  "/home": (BuildContext context) => new LoginScreen(),
};

Main application

Adding routes are pretty simple, first you need to setup the different routes as a Map object. We only have three routes, ‘/’ is the default route.

/home/nguyenthanhluan/ruby/flutter/chat_app/chat/lib/main.dart
import 'package:chat/src/widgets/login/login_screen.dart';
import 'package:flutter/material.dart';
import 'package:chat/src/routes.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';

final ThemeData kIOSTheme = new ThemeData(
  primarySwatch: Colors.orange,
  primaryColor: Colors.grey[100],
  primaryColorBrightness: Brightness.light,
);

final ThemeData kDefaultTheme = new ThemeData(
  primarySwatch: Colors.blue,
  accentColor: Colors.orangeAccent[400],
);
void main() => runApp(new MaterialApp(
      title: 'Chat App',
      theme: defaultTargetPlatform == TargetPlatform.iOS
          ? kIOSTheme
          : kDefaultTheme,
      home: new LoginScreen(),
      routes: routes,
    ));

Http Client

/home/nguyenthanhluan/ruby/flutter/chat_app/chat/lib/src/utils/network_util.dart
import 'dart:async';
import 'package:http/http.dart' as http;

class NetworkUtil {
  // next three lines makes this class a Singleton
  static NetworkUtil _instance = new NetworkUtil.internal();
  NetworkUtil.internal();
  factory NetworkUtil() => _instance;

  Future<http.Response> get(String url, Map headers) {
    return http.get(url, headers: headers).then((http.Response response) {
      return handleResponse(response);
    });
  }

  Future<http.Response> post(String url, {Map headers, body, encoding}) {
    return http
        .post(url, body: body, headers: headers, encoding: encoding)
        .then((http.Response response) {
      return handleResponse(response);
    });
  }

  Future<http.Response> delete(String url, {Map headers}) {
    return http
        .delete(
      url,
      headers: headers,
    )
        .then((http.Response response) {
      return handleResponse(response);
    });
  }

  Future<http.Response> put(String url, {Map headers, body, encoding}) {
    return http
        .put(url, body: body, headers: headers, encoding: encoding)
        .then((http.Response response) {
      return handleResponse(response);
    });
  }

  http.Response handleResponse(http.Response response) {
    final int statusCode = response.statusCode;

    if (statusCode == 401) {
      throw new Exception("Unauthorized");
    } else if (statusCode != 200) {
      throw new Exception("Error while fetching data");
    }

    return response;
  }
}

RestApi Client

We need a network util class that can wrap get and post requests plus handle encoding / decoding of JSONs

/home/nguyenthanhluan/ruby/flutter/chat_app/chat/lib/src/data/rest_ds.dart

import 'dart:async';
import 'dart:convert';

import 'package:chat/src/constant.dart';
import 'package:chat/src/data/database_helper.dart';
import 'package:chat/src/models/auth.dart';
import 'package:chat/src/models/message.dart';
import 'package:chat/src/utils/network_util.dart';

class RestDatasource {
  NetworkUtil _netUtil = new NetworkUtil();

  Future<Auth> login(String email, String password, String deviceToken) {
    var loginUrl = "$backendUrl/api/auth/sign_in";
    return _netUtil.post(loginUrl, body: {
      "email": email,
      "password": password,
      "device_token": deviceToken,
    }).then((dynamic res) {
      var body = JSON.decode(res.body);

      print(body.toString());
      if (body["error"] != null) throw new Exception(body["error_msg"]);
      return new Auth.map(res, deviceToken);
    });
  }

  Future<ListMessage> getMessages(int page) {
    var messageUrl = "$backendUrl/api/messages?page=$page";
    return getHeaders().then((dynamic headers) {
      return _netUtil.get(messageUrl, headers).then((dynamic res) {
        var body = JSON.decode(res.body);

        print(body.toString());
        if (body["error"] != null) throw new Exception(body["error_msg"]);

        return new ListMessage.map(body);
      });
    });
  }

  Future logout() {
    var messageUrl = "$backendUrl/api/auth/sign_out";
    return getHeaders().then((dynamic headers) {
      return _netUtil.delete(messageUrl, headers: headers).then((dynamic res) {
        var body = JSON.decode(res.body);

        print(body.toString());
        if (body["error"] != null) throw new Exception(body["error_msg"]);

        return;
      });
    });
  }

  Future<dynamic> readAll() {
    var userRoomUrl = "$backendUrl/api/user_room";
    return getHeaders().then((dynamic headers) {
      return _netUtil
          .put(userRoomUrl, body: {}, headers: headers)
          .then((dynamic res) {
        try {
          var body = JSON.decode(res.body);
          print(body.toString());
          if (body["error"] != null) throw new Exception(body["error_msg"]);
          return body;
        } catch (_) {
          return {};
        }
      });
    });
  }

  Future<dynamic> getHeaders() async {
    var db = new DatabaseHelper();
    var auth = await db.getAuth();
    return {
      "UID": auth.uid,
      "ACCESS_TOKEN": auth.accessToken,
      "DEVICE_TOKEN": auth.deviceToken,
      "CLIENT": auth.clientId
    };
  }
}

RestDatasource is a data source that uses a rest backend for login_app, which I assume you already have. ‘/login’ route expects three parameters: username, password and a device token key

Models

/home/nguyenthanhluan/ruby/flutter/chat_app/chat/lib/src/models/auth.dart

import 'dart:convert';

class Auth {
  int _id;
  String _email;
  String _name;
  String _avatar;

  String _deviceToken;
  String _accessToken;
  String _uid;
  String _clientId;
  Auth(this._uid, this._accessToken);

  Auth.map(dynamic response, String deviceToken) {
    var obj = JSON.decode(response.body)["data"];
    var headers = response.headers;

    this._deviceToken = deviceToken;

    this._id = obj["id"];
    this._email = obj["email"];
    this._name = obj["name"];
    this._avatar = obj["avatar"];
    this._accessToken = headers["access-token"];
    this._clientId = headers["client"];
    this._uid = headers["uid"];
  }

  Auth.fromMap(dynamic obj) {
    this._id = obj["id"];
    this._email = obj["email"];
    this._name = obj["name"];
    this._avatar = obj["avatar"];

    this._uid = obj["uid"];
    this._accessToken = obj["accessToken"];
    this._deviceToken = obj["deviceToken"];
    this._clientId = obj["clientId"];
  }

  int get id => _id;
  String get email => _email;
  String get name => _name;
  String get avatar => _avatar;
  String get uid => _uid;
  String get accessToken => _accessToken;
  String get deviceToken => _deviceToken;
  String get clientId => _clientId;

  Map<String, dynamic> toMap() {
    var map = new Map<String, dynamic>();
    map["id"] = _id;
    map["email"] = _email;
    map["name"] = _name;
    map["avatar"] = _avatar;
    map["uid"] = _uid;
    map["accessToken"] = _accessToken;
    map["deviceToken"] = _deviceToken;
    map["clientId"] = _clientId;

    return map;
  }
}
/home/nguyenthanhluan/ruby/flutter/chat_app/chat/lib/src/models/user.dart

class User {
  int _id;
  String _email;
  String _name;
  String _avatar;
  String _password;
  User(this._email, this._password);

  User.map(dynamic obj) {
    this._id = obj["id"];
    this._email = obj["email"];
    this._name = obj["name"];
    this._avatar = obj["avatar"];
    this._password = obj["password"];
  }

  int get id => _id;
  String get email => _email;
  String get name => _name;
  String get password => _password;
  String get avatar => _avatar;

  Map<String, dynamic> toMap() {
    var map = new Map<String, dynamic>();
    map["id"] = _id;
    map["email"] = _email;
    map["name"] = _name;
    map["_avatar"] = _avatar;
    map["password"] = _password;

    return map;
  }
}

/home/nguyenthanhluan/ruby/flutter/chat_app/chat/lib/src/models/message.dart
import 'package:chat/src/models/user.dart';

class Message {
  int _id;
  String _text;
  String _name;
  DateTime _created_at;
  User _user;

  Message.map(dynamic obj) {
    this._id = obj["id"];
    this._text = obj["text"];
    this._user = new User.map(obj["user"]);
    this._name = obj["name"];
    this._created_at = DateTime.tryParse(obj["created_at"]);
  }

  int get id => _id;
  String get text => _text;
  String get name => _name;
  DateTime get created_at => _created_at;
  User get user => _user;

  Map<String, dynamic> toMap() {
    var map = new Map<String, dynamic>();
    map["text"] = _text;
    map["name"] = _name;
    map["user"] = _user;
    map["created_at"] = _created_at;

    return map;
  }
}

class ListMessage {
  int _current_page;
  int _count;
  int _total_pages;
  int _total_count;
  List<Message> _messages = <Message>[];

  int get current_page => _current_page;
  int get count => _count;
  int get total_pages => _total_pages;
  int get total_count => _total_count;
  List<Message> get messages => _messages;

  ListMessage.map(dynamic obj) {
    this._current_page = obj["current_page"];
    this._count = obj["count"];
    this._total_pages = obj["total_pages"];
    this._total_count = obj["total_count"];

    for (final x in obj["messages"]) {
      this._messages.add(new Message.map(x));
    }
  }
}

Database adapter

/home/nguyenthanhluan/ruby/flutter/chat_app/chat/lib/src/data/database_helper.dart

import 'dart:async';
import 'dart:io' as io;

import 'package:chat/src/models/auth.dart';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path_provider/path_provider.dart';

class DatabaseHelper {
  static final DatabaseHelper _instance = new DatabaseHelper.internal();
  factory DatabaseHelper() => _instance;

  static Database _db;
  String _dbFile = "main.db";

  Future<Database> get db async {
    if (_db != null) return _db;
    _db = await initDb();
    return _db;
  }

  DatabaseHelper.internal();

  initDb() async {
    io.Directory documentsDirectory = await getApplicationDocumentsDirectory();
    String path = join(documentsDirectory.path, _dbFile);
    var theDb = await openDatabase(path, version: 1, onCreate: _onCreate);
    return theDb;
  }

  Future<String> deleteDb() async {
    io.Directory documentsDirectory = await getApplicationDocumentsDirectory();
    String path = join(documentsDirectory.path, _dbFile);
    await deleteDatabase(path);
    // _db.close();
    _db = null;

    return path;
  }

  void _onCreate(Database db, int version) async {
    // When creating the db, create the table
    await db.execute(
        "CREATE TABLE Auth(id INTEGER PRIMARY KEY, name TEXT, email TEXT, avatar TEXT, uid TEXT, accessToken TEXT, deviceToken TEXT, clientId TEXT);");

    print("Created tables");
  }

  Future<int> saveAuth(Auth auth) async {
    var dbClient = await db;
    int res = await dbClient.insert("Auth", auth.toMap());
    return res;
  }

  Future<Auth> getAuth() async {
    var dbClient = await db;
    var res = await dbClient.query("Auth", limit: 1);
    return new Auth.fromMap(res.first);
  }

  Future<bool> isLoggedIn() async {
    var dbClient = await db;
    var res = await dbClient.query("Auth");
    return res.length > 0 ? true : false;
  }
}

The above class uses SQFLite plugin for Flutter to handle insertion and deletion of User credentials to the database

Authenticate State

/home/nguyenthanhluan/ruby/flutter/chat_app/chat/lib/src/auth.dart
import 'package:chat/src/data/database_helper.dart';

enum AuthState { LOGGED_IN, LOGGED_OUT }

abstract class AuthStateListener {
  void onAuthStateChanged(AuthState state);
}

class AuthStateProvider {
  static final AuthStateProvider _instance = new AuthStateProvider.internal();

  List<AuthStateListener> _subscribers;

  factory AuthStateProvider() => _instance;
  AuthStateProvider.internal() {
    _subscribers = new List<AuthStateListener>();
    initState();
  }

  void initState() async {
    var db = new DatabaseHelper();
    var isLoggedIn = await db.isLoggedIn();
    if (isLoggedIn)
      notify(AuthState.LOGGED_IN);
    else
      notify(AuthState.LOGGED_OUT);
  }

  void subscribe(AuthStateListener listener) {
    _subscribers.add(listener);
  }

  void dispose(AuthStateListener listener) {
    _subscribers.removeWhere((l) => l == listener);
  }

  void notify(AuthState state) {
    _subscribers.forEach((AuthStateListener s) => s.onAuthStateChanged(state));
  }
}

auth.dart defines a Broadcaster/Observable kind of object that can notify its Subscribers of any change in AuthState (logged_in or not).

Login screen Presenter

/home/nguyenthanhluan/ruby/flutter/chat_app/chat/lib/src/widgets/login/login_screen_presenter.dart
import 'package:chat/src/data/database_helper.dart';
import 'package:chat/src/data/rest_ds.dart';
import 'package:chat/src/models/auth.dart';

abstract class LoginScreenContract {
  void onLoginSuccess();
  void onLoginError(String errorTxt);
}

class LoginScreenPresenter {
  LoginScreenContract _view;
  RestDatasource api = new RestDatasource();
  LoginScreenPresenter(this._view);

  doLogin(String email, String password, String deviceToken) {
    api.login(email, password, deviceToken).then((Auth auth) {
      var db = new DatabaseHelper();
      db.saveAuth(auth).then((_) {
        _view.onLoginSuccess();
      });
    }, onError: (e) {
      handleError(e);
    }).catchError(handleError);
  }

  handleError(Exception error) {
    _view.onLoginError(error.toString());
  }
}

login_screen_presenter.dart defines an interface for LoginScreen view and a presenter that incorporates all business logic specific to login screen itself.

Login screen Screen

/home/nguyenthanhluan/ruby/flutter/chat_app/chat/lib/src/widgets/login/login_screen.dart
import 'package:flutter/material.dart';
import 'package:chat/src/auth.dart';
import 'package:chat/src/widgets/login/login_screen_presenter.dart';

import 'package:firebase_messaging/firebase_messaging.dart';

class LoginScreen extends StatefulWidget {
  LoginScreen({Key key}) : super(key: key);

  @override
  State<StatefulWidget> createState() => new LoginScreenState();
}

class LoginScreenState extends State<LoginScreen>
    implements LoginScreenContract, AuthStateListener {
  bool _isLoading = false;
  final formKey = new GlobalKey<FormState>();
  final scaffoldKey = new GlobalKey<ScaffoldState>();
  String _email, _password;

  LoginScreenPresenter _presenter;
  final FirebaseMessaging _firebaseMessaging = new FirebaseMessaging();

  LoginScreenState() {
    _presenter = new LoginScreenPresenter(this);
    var authStateProvider = new AuthStateProvider();
    authStateProvider.subscribe(this);
  }
  @override
  void initState() {
    super.initState();
    _firebaseMessaging.configure();
    _firebaseMessaging.requestNotificationPermissions(
        const IosNotificationSettings(sound: true, badge: true, alert: true));
    _firebaseMessaging.onIosSettingsRegistered
        .listen((IosNotificationSettings settings) {
      print("Settings registered: $settings");
    });
  }

  @override
  void dispose() {
    var authStateProvider = new AuthStateProvider();
    authStateProvider.dispose(this);
    super.dispose();
  }

  void _submit() {
    final form = formKey.currentState;

    if (form.validate()) {
      setState(() => _isLoading = true);
      form.save();
      _firebaseMessaging.getToken().then((String token) {
        assert(token != null);
        print(token);
        setState(() {
          _presenter.doLogin(_email, _password, token);
        });
      });
    }
  }

  void _showSnackBar(String text) {
    scaffoldKey.currentState
        .showSnackBar(new SnackBar(content: new Text(text)));
  }

  @override
  onAuthStateChanged(AuthState state) {
    if (state == AuthState.LOGGED_IN) {
      Navigator.of(context).pushReplacementNamed("/chat");
    }
  }

  @override
  Widget build(BuildContext context) {
    var loginBtn = new RaisedButton(
      onPressed: _submit,
      child: new Text("LOGIN"),
      color: Colors.primaries[0],
    );
    var loginForm = new Column(
      children: <Widget>[
        new Text(
          "Login",
          textScaleFactor: 2.0,
        ),
        new Form(
          key: formKey,
          child: new Column(
            children: <Widget>[
              new Padding(
                padding: const EdgeInsets.all(8.0),
                child: new TextFormField(
                  onSaved: (val) => _email = val,
                  validator: (val) {
                    return val.length < 10
                        ? "User name must have atleast 10 chars"
                        : null;
                  },
                  decoration: new InputDecoration(labelText: "User name"),
                ),
              ),
              new Padding(
                padding: const EdgeInsets.all(8.0),
                child: new TextFormField(
                  onSaved: (val) => _password = val,
                  decoration: new InputDecoration(labelText: "Password"),
                  obscureText: true,
                ),
              ),
            ],
          ),
        ),
        _isLoading ? new CircularProgressIndicator() : loginBtn
      ],
      crossAxisAlignment: CrossAxisAlignment.center,
    );

    return new Scaffold(
      appBar: null,
      key: scaffoldKey,
      body: new Container(
        decoration: new BoxDecoration(),
        child: new Center(
          child: new Container(
            child: loginForm,
            height: 300.0,
            width: 300.0,
          ),
        ),
      ),
    );
  }

  @override
  void onLoginError(String errorTxt) {
    _showSnackBar(errorTxt);
    setState(() => _isLoading = false);
  }

  @override
  void onLoginSuccess() {
    setState(() => _isLoading = false);
    var authStateProvider = new AuthStateProvider();
    authStateProvider.notify(AuthState.LOGGED_IN);
  }
}

We got an FCM token of user device, store it on server, server will send FCM notification when there are new message on our Chat room.

Chat screen

/home/nguyenthanhluan/ruby/flutter/chat_app/chat/lib/src/widgets/chat/chat_screen.dart
import 'dart:async';
import 'dart:convert';

import 'package:chat/src/constant.dart';
import 'package:chat/src/data/database_helper.dart';
import 'package:chat/src/models/message.dart';
import 'package:chat/src/widgets/chat/chat_message.dart';
import 'package:chat/src/widgets/chat/chat_screen_presenter.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'package:web_socket_channel/io.dart';

class ChatScreen extends StatefulWidget {
  ChatScreen({Key key}) : super(key: key);

  @override
  State createState() => new ChatScreenState();
}

class ChatScreenState extends State<ChatScreen>
    with TickerProviderStateMixin
    implements ChatScreenContract {
  final List<ChatMessage> _messages = <ChatMessage>[];
  final TextEditingController _textController = new TextEditingController();

  bool _isComposing = false;
  int current_user_id;
  IOWebSocketChannel channel;
  ChatScreenPresenter _presenter;
  AppLifecycleState _lastLifecyleState;

  ScrollController _scrollController = new ScrollController();
  bool isPerformingRequest = false;

  ChatScreenState() {
    _presenter = new ChatScreenPresenter(this);
  }

  void onLoadMessageSuccess(ListMessage listMessage) {
    setState(() {
      for (var message in listMessage.messages) {
        _messages.add(new ChatMessage(
          current_user_id: current_user_id,
          message: message,
        ));
      }
    });
  }

  void onLoadMessageError(String errorMessage) {
    debugPrint(errorMessage);
  }

  void onLogoutSuccess() {
    Navigator.of(context).pushReplacementNamed("/login");
  }

  @override
  void initState() {
    super.initState();
    handleAppLifecycleState();
    setupChannel();
    debugPrint("Chat init");
    if (_messages.length == 0) {
      _presenter.loadMessages();
    }
    _scrollController.addListener(() {
      if (_scrollController.position.pixels ==
          _scrollController.position.maxScrollExtent) {
        _getMoreData();
      }
    });
  }

  void handleAppLifecycleState() {
    SystemChannels.lifecycle.setMessageHandler((msg) {
      debugPrint('SystemChannels> $msg');
      setState(() {
        switch (msg) {
          case "AppLifecycleState.paused":
            _lastLifecyleState = AppLifecycleState.paused;
            break;
          case "AppLifecycleState.inactive":
            _lastLifecyleState = AppLifecycleState.inactive;
            break;
          case "AppLifecycleState.resumed":
            _lastLifecyleState = AppLifecycleState.resumed;
            _presenter.readAll();
            break;
          case "AppLifecycleState.suspending":
            _lastLifecyleState = AppLifecycleState.suspending;
            break;
          default:
        }
      });
    });
  }

  void _getMoreData() {
    if (!isPerformingRequest) {
      setState(() => isPerformingRequest = true);
      _presenter.loadMessages();
      setState(() {
        isPerformingRequest = false;
      });
    }
  }

  void setupChannel() {
    var db = new DatabaseHelper();
    db.getAuth().then((auth) {
      current_user_id = auth.id;
      channel = new IOWebSocketChannel.connect(socketUrl, headers: {
        "UID": auth.uid,
        "ACCESS_TOKEN": auth.accessToken,
        "CLIENT_ID": auth.clientId
      });

      channel.sink.add(json.encode({
        "command": "subscribe",
        "identifier": "{\"channel\":\"RoomChannel\"}"
      }));

      channel.stream.listen(onData);
    });
  }

  void _handleSubmitted(String text) {
    _textController.clear();
    setState(() {
      _isComposing = false;
    });

    channel.sink.add(json.encode({
      "command": "message",
      "identifier": "{\"channel\":\"RoomChannel\"}",
      "data": "{\"action\":\"speak\", \"message\":\"${text}\"}"
    }));
  }

  void dispose() {
    for (ChatMessage message in _messages)
      if (message.animationController != null)
        message.animationController.dispose();

    channel.sink.close();
    _scrollController.dispose();
    super.dispose();
    print("Dispose Chat");
  }

  void logout() {
    _presenter.logout();
  }

  void onData(_data) {
    var data = JSON.decode(_data);
    switch (data["type"]) {
      case "ping":
        break;
      case "welcome":
        print("Welcome");
        break;
      case "confirm_subscription":
        print("Connected");
        break;
      default:
        print(data.toString());
    }

    if (data["identifier"] == "{\"channel\":\"RoomChannel\"}" &&
        data["type"] != "confirm_subscription") {
      var msg = Message.map(data["message"]["message"]);

      ChatMessage message = new ChatMessage(
        current_user_id: current_user_id,
        message: msg,
        animationController: new AnimationController(
          duration: new Duration(milliseconds: 700),
          vsync: this,
        ),
      );
      setState(() {
        _messages.insert(0, message);
      });
      _scrollController.jumpTo(0.0);
      message.animationController.forward();

      if (_lastLifecyleState == AppLifecycleState.resumed ||
          _lastLifecyleState == null) {
        _presenter.readAll();
      }
    }
  }

  Widget _buildTextComposer() {
    return new IconTheme(
      data: new IconThemeData(color: Theme.of(context).accentColor),
      child: new Container(
          margin: const EdgeInsets.symmetric(horizontal: 8.0),
          child: new Row(children: <Widget>[
            new Flexible(
              child: new TextField(
                controller: _textController,
                onChanged: (String text) {
                  setState(() {
                    _isComposing = text.length > 0;
                  });
                },
                onSubmitted: _handleSubmitted,
                decoration:
                    new InputDecoration.collapsed(hintText: "Send a message"),
              ),
            ),
            new Container(
                margin: new EdgeInsets.symmetric(horizontal: 4.0),
                child: Theme.of(context).platform == TargetPlatform.iOS
                    ? new CupertinoButton(
                        child: new Text("Send"),
                        onPressed: _isComposing
                            ? () => _handleSubmitted(_textController.text)
                            : null,
                      )
                    : new IconButton(
                        icon: new Icon(Icons.send),
                        onPressed: _isComposing
                            ? () => _handleSubmitted(_textController.text)
                            : null,
                      )),
          ]),
          decoration: Theme.of(context).platform == TargetPlatform.iOS
              ? new BoxDecoration(
                  border:
                      new Border(top: new BorderSide(color: Colors.grey[200])))
              : null),
    );
  }

  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: new Text("Room"),
        elevation: Theme.of(context).platform == TargetPlatform.iOS ? 0.0 : 4.0,
        actions: <Widget>[
          new IconButton(
            icon: new Icon(Icons.exit_to_app),
            tooltip: 'Logout',
            onPressed: logout,
          )
        ],
      ),
      body: new Container(
          child: new Column(children: <Widget>[
            new Flexible(
              child: new ListView.builder(
                padding: new EdgeInsets.all(8.0),
                controller: _scrollController,
                reverse: true,
                itemBuilder: (_, int index) => _messages[index],
                itemCount: _messages.length,
              ),
            ),
            new Divider(height: 1.0),
            new Container(
              decoration: new BoxDecoration(color: Theme.of(context).cardColor),
              child: _buildTextComposer(),
            ),
          ]),
          decoration: Theme.of(context).platform == TargetPlatform.iOS
              ? new BoxDecoration(
                  border:
                      new Border(top: new BorderSide(color: Colors.grey[200])))
              : null), //new
    );
  }
}

Chat screen Presenter

/home/nguyenthanhluan/ruby/flutter/chat_app/chat/lib/src/widgets/chat/chat_screen_presenter.dart
import 'package:chat/src/data/database_helper.dart';
import 'package:chat/src/data/rest_ds.dart';
import 'package:chat/src/models/message.dart';
import 'package:flutter_app_badger/flutter_app_badger.dart';

abstract class ChatScreenContract {
  void onLoadMessageSuccess(ListMessage messages);
  void onLoadMessageError(String errorMessage);
  void onLogoutSuccess();
}

class ChatScreenPresenter {
  ChatScreenContract _view;
  RestDatasource api = new RestDatasource();
  ChatScreenPresenter(this._view);
  int current_page = 0;
  loadMessages() {
    api.getMessages(current_page + 1).then((ListMessage messages) {
      if (current_page < messages.total_pages) {
        current_page++;
      }

      updateBadger();
      _view.onLoadMessageSuccess(messages);
    }).catchError(
        (Exception error) => _view.onLoadMessageError(error.toString()));
  }

  void logout() {
    api.logout().then((dynamic _) {
      var db = new DatabaseHelper();
      db.deleteDb().then((_) {
        _view.onLogoutSuccess();
      });
    });
  }

  void readAll() {
    api.readAll().then((dynamic _) {
      updateBadger();
    });
  }

  void updateBadger() {
    FlutterAppBadger.isAppBadgeSupported().then((isSupported) {
      if (isSupported) FlutterAppBadger.removeBadge();
      // FlutterAppBadger.updateBadgeCount(1);
    });
  }
}

Chat Screen Message Item

/home/nguyenthanhluan/ruby/flutter/chat_app/chat/lib/src/widgets/chat/chat_message.dart


import 'package:flutter/material.dart';

class ChatMessage extends StatelessWidget {
  ChatMessage(
      {this.id,
      this.current_user_id,
      this.name,
      this.text,
      this.animationController});
  final int id;
  final int current_user_id;
  final String text;
  final String name;
  final AnimationController animationController;
  @override
  Widget build(BuildContext context) {
    if (current_user_id == id) {
      return _buildRight(context);
    } else {
      return _build(context);
    }
  }

  Widget _build(BuildContext context) {
    var container = new Container(
      margin: const EdgeInsets.symmetric(vertical: 10.0),
      child: new Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          new Container(
            margin: const EdgeInsets.only(right: 16.0),
            child: new CircleAvatar(child: new Text(name[0])),
          ),
          new Expanded(
            child: new Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                new Text(name, style: Theme.of(context).textTheme.title),
                new Container(
                  margin: const EdgeInsets.only(top: 5.0),
                  child: new Text(text),
                ),
              ],
            ),
          ),
        ],
      ),
    );
    if (animationController != null) {
      return new SizeTransition(
          sizeFactor: new CurvedAnimation(
              parent: animationController, curve: Curves.easeOut),
          axisAlignment: 0.0,
          child: container);
    } else {
      return container;
    }
  }

  Widget _buildRight(BuildContext context) {
    var container = new Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        new Expanded(
          child: new Column(
            crossAxisAlignment: CrossAxisAlignment.end,
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              new Text(name, style: Theme.of(context).textTheme.title),
              new Container(
                margin: const EdgeInsets.only(top: 5.0, right: 4.0),
                child: new Text(text),
              ),
            ],
          ),
        ),
        new Container(
          margin: const EdgeInsets.only(right: 16.0, left: 2.0),
          child: new CircleAvatar(child: new Text(name[0])),
        ),
      ],
    );

    if (animationController != null) {
      return new SizeTransition(
          sizeFactor: new CurvedAnimation(
              parent: animationController, curve: Curves.easeOut),
          axisAlignment: 0.0,
          child: new Container(
            margin: const EdgeInsets.symmetric(vertical: 10.0),
            child: container,
          ));
    } else {
      return container;
    }
  }
}

Application constant

/home/nguyenthanhluan/ruby/flutter/chat_app/chat/lib/src/constant.dart
const String backendUrl = "http://192.168.16.104:3000";
const String socketUrl = "ws://192.168.16.104:3000/cable";

Handle Firebase Messaging Background

I don't found any way to create background service using dart, so i write service using native android

/home/nguyenthanhluan/ruby/flutter/chat_app/chat/android/app/src/main/java/com/example/chat/MyFirebaseMessagingService.java
package com.example.chat;

import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.support.v4.app.RemoteInput;
import android.content.Context;
import android.content.Intent;
import android.content.ComponentName;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.media.RingtoneManager;
import android.support.v4.app.NotificationCompat;
import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;
import java.util.Map;
import java.util.List;
import static android.R.drawable.ic_delete;
import org.json.JSONObject;

import android.app.Activity;
import android.app.ActivityManager;
import android.app.ActivityManager.RecentTaskInfo;
import android.app.ActivityManager.RunningAppProcessInfo;
import android.app.ActivityManager.RunningServiceInfo;
import android.app.ActivityManager.RunningTaskInfo;
import android.app.KeyguardManager;
import android.support.v4.app.TaskStackBuilder;

import me.leolin.shortcutbadger.ShortcutBadger;

public class MyFirebaseMessagingService extends FirebaseMessagingService {

  @Override
  public void onMessageReceived(RemoteMessage remoteMessage) {
    super.onMessageReceived(remoteMessage);
    if (isLocked() || !isRunning("com.example.chat")) {
      RemoteMessage.Notification notification = remoteMessage.getNotification();
      Map<String, String> data = remoteMessage.getData();
      sendNotification(notification, data);
    }
  }

  public boolean isRunning(String myPackage) {
    ActivityManager manager = (ActivityManager) getSystemService(ACTIVITY_SERVICE);
    List<ActivityManager.RunningTaskInfo> runningTaskInfo = manager.getRunningTasks(1);
    ComponentName componentInfo = runningTaskInfo.get(0).topActivity;

    System.out.println(componentInfo.getClassName());

    return componentInfo.getPackageName().equals(myPackage);
  }

  public boolean isLocked() {
    KeyguardManager myKM = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE);
    return myKM.inKeyguardRestrictedInputMode();
  }

  private void sendNotification(RemoteMessage.Notification notification, Map<String, String> data) {
    Bitmap icon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher);

    try {
      // Create an Intent for the activity you want to start
      Intent resultIntent = new Intent(this, MainActivity.class).setAction(Intent.ACTION_MAIN)
          .addCategory("FLUTTER_NOTIFICATION_CLICK")
          .setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
      PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, resultIntent, PendingIntent.FLAG_UPDATE_CURRENT);

      JSONObject jo = new JSONObject(data.get("user"));

      String name = jo.getString("name");
      // int id = Integer.parseInt(data.get("id"));
      int id = 0;
      String text = data.get("text");
      int unread_count = Integer.parseInt(data.get("unread_count"));

      NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this, "channel_id")
          .setContentTitle(name).setContentText(text).setAutoCancel(true)
          .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
          .setDefaults(Notification.DEFAULT_VIBRATE).setSmallIcon(R.mipmap.ic_launcher).setContentIntent(pendingIntent)
          .setPriority(Notification.PRIORITY_MAX).setContentInfo(text).setLargeIcon(icon).setColor(Color.BLUE)
          .setLights(Color.GREEN, 1000, 300);

      NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);

      Notification _notification = notificationBuilder.build();

      ShortcutBadger.applyCount(getApplicationContext(), unread_count);
      ShortcutBadger.applyNotification(getApplicationContext(), _notification, unread_count);

      notificationManager.notify(id, _notification);

    } catch (Exception e) {
      // TODO: handle exception
      System.out.println(e);
    }
  }
}


/home/nguyenthanhluan/ruby/flutter/chat_app/chat/android/app/src/main/AndroidManifest.xml


        <service
            android:name=".MyFirebaseMessagingService">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT"/>
            </intent-filter>
        </service>

##Implement Server side

Gem

/home/nguyenthanhluan/ruby/flutter/chat_app/chat_backend/Gemfile

gem 'devise_token_auth'
gem "active_model_serializers"
gem 'kaminari'
gem "fcm"

Routes

/home/nguyenthanhluan/ruby/flutter/chat_app/chat_backend/config/routes.rb


Rails.application.routes.draw do
  mount_devise_token_auth_for 'User', at: 'api/auth', controllers: { sessions: 'api/auth/sessions' }

  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
  namespace :api, defaults: {format: "json"} do
    resources :messages, only: [:index, :update]
    resource :user_room, only: [:update]
  end
end

Models

/home/ubuntu/ruby/flutter/flutter_chat/chat_backend/app/models/user.rb
 class User < ActiveRecord::Base
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, :validatable
  include DeviseTokenAuth::Concerns::User

  has_many :device_tokens, dependent: :destroy
  has_many :messages, dependent: :destroy
  has_one :user_room, dependent: :destroy

  validates :name, presence: true

  def unread_count
    Message.unread(user_room.read_at, id).size
  end
end

/home/ubuntu/ruby/flutter/flutter_chat/chat_backend/app/models/device_token.rb
class DeviceToken < ApplicationRecord
  validates :token, presence: true, uniqueness: true
end

/home/ubuntu/ruby/flutter/flutter_chat/chat_backend/app/models/user_room.rb
class UserRoom < ApplicationRecord
end

/home/ubuntu/ruby/flutter/flutter_chat/chat_backend/app/models/message.rb
class Message < ApplicationRecord
  belongs_to :user
  delegate :id, :name, to: :user, prefix: true, allow_nil: true

  scope :unread, -> read_at, user_id do
    where("messages.user_id != ?", user_id)
    .where(" ? < (SELECT MAX(messages.created_at))", read_at.try(:utc))
  end
end

Sign in - Sign out

/home/nguyenthanhluan/ruby/flutter/chat_app/chat_backend/app/controllers/api/auth/sessions_controller.rb

class Api::Auth::SessionsController < DeviseTokenAuth::SessionsController
  def create
    # Check
    field = (resource_params.keys.map(&:to_sym) & resource_class.authentication_keys).first

    @resource = nil
    if field
      q_value = get_case_insensitive_field_from_resource_params(field)

      @resource = find_resource(field, q_value)
    end

    if @resource && valid_params?(field, q_value) && (!@resource.respond_to?(:active_for_authentication?) || @resource.active_for_authentication?)
      valid_password = @resource.valid_password?(resource_params[:password])
      if (@resource.respond_to?(:valid_for_authentication?) && !@resource.valid_for_authentication? { valid_password }) || !valid_password
        return render_create_error_bad_credentials
      end
      @client_id, @token = @resource.create_token
      @resource.save

      DeviceToken.where(token: params[:device_token]).destroy_all

      @resource.device_tokens.create token: params[:device_token]
      sign_in(:user, @resource, store: false, bypass: false)

      yield @resource if block_given?

      render_create_success
    elsif @resource && !(!@resource.respond_to?(:active_for_authentication?) || @resource.active_for_authentication?)
      if @resource.respond_to?(:locked_at) && @resource.locked_at
        render_create_error_account_locked
      else
        render_create_error_not_confirmed
      end
    else
      render_create_error_bad_credentials
    end
  end

  def destroy
    # remove auth instance variables so that after_action does not run
    user = remove_instance_variable(:@resource) if @resource
    client_id = remove_instance_variable(:@client_id) if @client_id
    remove_instance_variable(:@token) if @token

    if user && client_id && user.tokens[client_id]
      user.tokens.delete(client_id)
      user.save!

      DeviceToken.where(token: request.headers["HTTP_DEVICE_TOKEN"]).destroy_all

      yield user if block_given?

      render_destroy_success
    else
      render_destroy_error
    end
  end
end

Add device token on login, remove on logout

Base Authorize controller

/home/nguyenthanhluan/ruby/flutter/chat_app/chat_backend/app/controllers/api/auth_controller.rb

class Api::AuthController < ApplicationController
  include DeviseTokenAuth::Concerns::SetUserByToken
  before_action :authenticate_user!
end

Messages controller

/home/nguyenthanhluan/ruby/flutter/chat_app/chat_backend/app/controllers/api/messages_controller.rb
class Api::MessagesController < Api::AuthController
  def index
    messages = Message.includes(:user).order(created_at: :desc).page(params[:page]).per 20
    current_user.user_room.update read_at: Time.zone.now
    render json: MessageListSerializer.new(messages: messages).generate
  end
end

Unread Message

/home/nguyenthanhluan/ruby/flutter/chat_app/chat_backend/app/controllers/api/user_rooms_controller.rb
class Api::UserRoomsController < Api::AuthController
  def update
    current_user.user_room.update read_at: Time.zone.now
    render status: :ok
  end
end

Update read_at time, to calculate user's unread number of messages.

Serializers

/home/nguyenthanhluan/ruby/flutter/chat_app/chat_backend/app/serializers/user_serializer.rb
class UserSerializer < ActiveModel::Serializer
  attributes :id, :name, :email
end
/home/nguyenthanhluan/ruby/flutter/chat_app/chat_backend/app/serializers/message_serializer.rb
class MessageSerializer < ActiveModel::Serializer
  attributes :id, :text, :created_at
  belongs_to :user, serializer: UserSerializer
end
/home/nguyenthanhluan/ruby/flutter/chat_app/chat_backend/app/serializers/message_list_serializer.rb

class MessageListSerializer < ActiveModel::Serializer
  attr_reader :messages

  def initialize arg
    @messages = arg[:messages]
  end

  def generate
    {
      current_page: messages.current_page,
      count: messages.size,
      total_pages: messages.total_pages,
      total_count: messages.total_count,
      messages: ActiveModelSerializers::SerializableResource
        .new(messages, each_serializer: MessageSerializer)
    }
  end
end

paginate messages

ActionCable

/home/nguyenthanhluan/ruby/flutter/chat_app/chat_backend/app/channels/application_cable/connection.rb

module ApplicationCable
  class Connection < ActionCable::Connection::Base

    attr_reader :access_token, :current_user
    identified_by :token, :uid, :client_id

    def connect
      @uid = request.env["HTTP_UID"]
      @token = request.env["HTTP_ACCESS_TOKEN"]
      @client_id = request.env["HTTP_CLIENT_ID"]
      find_verified_user
    end

    private
    def find_verified_user
      user = User.find_by uid: uid
      if user && user.valid_token?(@token, @client_id)
        @current_user = user
      else
        reject_unauthorized_connection
      end
    end
  end
end

Authorize socket connection

/home/nguyenthanhluan/ruby/flutter/chat_app/chat_backend/app/channels/room_channel.rb

class RoomChannel < ApplicationCable::Channel
  delegate :current_user, to: :connection, prefix: nil

  def subscribed
    stream_from "room_channel"
  end

  def unsubscribed
  end

  def speak data
    BroadcastMessageService.new(user: current_user, data: data).perform
  end
end

Background Job

/home/nguyenthanhluan/ruby/flutter/chat_app/chat_backend/app/jobs/send_notification_job.rb

class SendNotificationJob < ApplicationJob
  require 'fcm'
  attr_reader :fcm

  queue_as :default

  def fcm
    @fcm ||= FCM.new(ENV['FCM_SERVER_KEY'] || 'AAAAZRdrMDs:APA91bGg8Q0z4bmdnHPPt696_wFPgg-E9yXQlEIw3KWzRwPUAN87lufxS1xlMtPVfA__nVfaOGU_J7XAv1ZfHODVOldVU2Ki03ybdp5v9NyqxtE0LVvlGg6BuvXwEctxRGD2TRoXM1W-')
  end

  def perform(message_id)
    message = Message.find message_id

    User.includes(:device_tokens).all.each do |user|
      options = { data: {
        action: :chat,
        unread_count: user.unread_count,
        id: message.id,
        text: message.text,
        created_at: message.created_at.to_s,
        user: { id: message.user_id, name: message.user_name }
      } }
      device_tokens = user.device_tokens.pluck(:token)
      puts "User #{user.id}: Devices: #{device_tokens.count} Unread: #{user.unread_count}"
      fcm.send device_tokens, options
    end
  end
end

Broadcast Message Service

/home/nguyenthanhluan/ruby/flutter/chat_app/chat_backend/app/services/broadcast_message_service.rb
class BroadcastMessageService
  attr_reader :user, :data
  def initialize args
    @user = args[:user]
    @data = args[:data]
  end

  def perform
    message = user.messages.create text: data["message"]

    ActionCable.server.broadcast "room_channel", message: {
      id: message.id,
      user: {id: user.id, name: user.name},
      text: data["message"]
    }

    SendNotificationJob.perform_later message.id
  end
end

Broadcast message through websocket and FCM

#Thanks

Thank for helpful article https://medium.com/@kashifmin/flutter-login-app-using-rest-api-and-sqflite-b4815aed2149

Thank for reading, this is my repository: https://github.com/n-luan/flutter_chat

19
17
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?