#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.
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.
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.
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
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
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
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;
}
}
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;
}
}
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
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
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
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
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
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
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
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
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
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);
}
}
}
<service
android:name=".MyFirebaseMessagingService">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
</intent-filter>
</service>
##Implement Server side
Gem
gem 'devise_token_auth'
gem "active_model_serializers"
gem 'kaminari'
gem "fcm"
Routes
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
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
class DeviceToken < ApplicationRecord
validates :token, presence: true, uniqueness: true
end
class UserRoom < ApplicationRecord
end
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
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
class Api::AuthController < ApplicationController
include DeviseTokenAuth::Concerns::SetUserByToken
before_action :authenticate_user!
end
Messages controller
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
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
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :email
end
class MessageSerializer < ActiveModel::Serializer
attributes :id, :text, :created_at
belongs_to :user, serializer: UserSerializer
end
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
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
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
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
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