diff --git a/lib/helpers/account_credentials_helper.dart b/lib/helpers/account_credentials_helper.dart index 04d552b..3c3409f 100644 --- a/lib/helpers/account_credentials_helper.dart +++ b/lib/helpers/account_credentials_helper.dart @@ -1,3 +1,8 @@ +import 'dart:convert'; + +import 'package:comunic/models/login_tokens.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + /// Accounts credentials helper /// /// Stores current account tokens @@ -8,7 +13,27 @@ class AccountCredentialsHelper { /// Checkout whether current user is signed in or not Future signedIn() async { - return false; // TODO : implement + return await get() != null; } + /// Set new login tokens + Future set(LoginTokens tokens) async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setString("login_tokens", tokens.toString()); + } + + /// Get current [LoginTokens]. Returns null if none or in case of failure + Future get() async { + try { + + SharedPreferences prefs = await SharedPreferences.getInstance(); + final string = prefs.getString("login_tokens"); + if(string == null) return null; + return LoginTokens.fromJSON(jsonDecode(string)); + + } on Exception catch(e){ + print(e.toString()); + return null; + } + } } \ No newline at end of file diff --git a/lib/helpers/account_helper.dart b/lib/helpers/account_helper.dart new file mode 100644 index 0000000..f4873ff --- /dev/null +++ b/lib/helpers/account_helper.dart @@ -0,0 +1,41 @@ +import 'package:comunic/helpers/account_credentials_helper.dart'; +import 'package:comunic/helpers/api_helper.dart'; +import 'package:comunic/models/api_request.dart'; +import 'package:comunic/models/authentication_details.dart'; +import 'package:comunic/models/login_tokens.dart'; + +/// Account helper +/// +/// @author Pierre HUBERT + +enum AuthResult { + SUCCESS, + TOO_MANY_ATTEMPTS, + NETWORK_ERROR, + INVALID_CREDENTIALS +} + +class AccountHelper { + /// Sign in user + Future signIn(AuthenticationDetails auth) async { + final request = APIRequest(uri: "account/login"); + request.addString("userMail", auth.email); + request.addString("userPassword", auth.password); + + final response = await APIHelper().exec(request); + + // Handle errors + if (response.code == 401) + return AuthResult.INVALID_CREDENTIALS; + else if (response.code == 429) + return AuthResult.TOO_MANY_ATTEMPTS; + else if (response.code != 200) return AuthResult.NETWORK_ERROR; + + //Save login tokens + final tokensObj = response.getObject()["tokens"]; + await AccountCredentialsHelper() + .set(LoginTokens(tokensObj["token1"], tokensObj["token2"])); + + return AuthResult.SUCCESS; + } +} diff --git a/lib/helpers/api_helper.dart b/lib/helpers/api_helper.dart new file mode 100644 index 0000000..2f486b2 --- /dev/null +++ b/lib/helpers/api_helper.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:comunic/models/api_request.dart'; +import 'package:comunic/models/api_response.dart'; +import 'package:comunic/models/config.dart'; + +/// API Helper +/// +/// @author Pierre HUBERT + +class APIHelper { + final _httpClient = HttpClient(); + + /// Execute a [request] on the server and returns a [APIResponse] + /// + /// This method should never throw but the response code of the [APIResponse] + /// should be verified before accessing response content + Future exec(APIRequest request) async { + try { + //Add API tokens + request.addString("serviceName", config().serviceName); + request.addString("serviceToken", config().serviceToken); + + //Add user tokens (if required) + if (request.needLogin) throw "Can add user tokens right now !"; + + // Prepare request body + String requestBody = ""; + request.args.forEach((key, value) => requestBody += + Uri.encodeQueryComponent(key) + + "=" + + Uri.encodeQueryComponent(value) + + "&"); + + List bodyBytes = utf8.encode(requestBody); + + // Determine server URL + final path = config().apiServerUri + request.uri; + Uri url; + if (!config().apiServerSecure) + url = Uri.http(config().apiServerName, path); + else + url = Uri.https(config().apiServerName, path); + + //Connect to server + final connection = await _httpClient.postUrl(url); + connection.headers.set("Content-Length", bodyBytes.length.toString()); + connection.headers + .set("Content-Type", "application/x-www-form-urlencoded"); + connection.add(bodyBytes); + + final response = await connection.close(); + + if (response.statusCode != HttpStatus.ok) + return APIResponse(response.statusCode, null); + + //Save & return response + final responseBody = await response.transform(utf8.decoder).join(); + + return APIResponse(response.statusCode, responseBody); + } on Exception catch (e) { + print(e.toString()); + print("Could not execute a request!"); + return APIResponse(-1, null); + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 122dbcc..ad8700f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,8 @@ import 'package:comunic/helpers/account_credentials_helper.dart'; -import 'package:comunic/ui/login_route.dart'; +import 'package:comunic/models/config.dart'; +import 'package:comunic/ui/routes/home_route.dart'; +import 'package:comunic/ui/routes/login_route.dart'; +import 'package:comunic/utils/ui_utils.dart'; import 'package:flutter/material.dart'; /// Main file of the application @@ -7,6 +10,16 @@ import 'package:flutter/material.dart'; /// @author Pierre HUBERT void main() { + /// Initialize application configuration + /// + // TODO : Adapt to production + Config.set(Config( + apiServerName: "devweb.local", + apiServerUri: "/comunic/api/", + apiServerSecure: false, + serviceName: "ComunicFlutter", + serviceToken: "G9sZCBmb3IgVWJ1bnR1CkNvbW1lbnRbbmVdPeCkieCkrOCkq")); + runApp(ComunicApplication()); } @@ -15,20 +28,37 @@ class ComunicApplication extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, - // We need to know whether the user is signed in or not to continue - home: FutureBuilder( - future: AccountCredentialsHelper().signedIn(), - builder: (b, AsyncSnapshot c){ - if(!c.hasData) - return CircularProgressIndicator(); - - else - if(c.data) - throw "Not supported yet !"; - else - return LoginRoute(); - }, - ), + home: ComunicApplicationHome(), ); } } + +class ComunicApplicationHome extends StatefulWidget { + @override + State createState() => _ComunicApplicationHomeState(); +} + +class _ComunicApplicationHomeState extends State { + bool _signedIn; + + @override + void initState() { + super.initState(); + + AccountCredentialsHelper().signedIn().then((v) { + setState(() { + _signedIn = v; + }); + }); + } + + @override + Widget build(BuildContext context) { + if (_signedIn == null) return buildLoadingPage(); + + if (_signedIn) + return HomeRoute(); + else + return LoginRoute(); + } +} diff --git a/lib/models/api_request.dart b/lib/models/api_request.dart new file mode 100644 index 0000000..9ae2e74 --- /dev/null +++ b/lib/models/api_request.dart @@ -0,0 +1,27 @@ +import 'package:meta/meta.dart'; + +/// API Request model +/// +/// Contains all the information associated to an API request +/// +/// @author Pierre HUBERT + +class APIRequest { + final String uri; + final bool needLogin; + Map args; + + APIRequest({ + @required this.uri, + this.needLogin = false, + }) : assert(uri != null), + assert(needLogin != null), + args = Map(); + + void addString(String name, String value) => args[name] = value; + + void addInt(String name, int value) => args[name] = value.toString(); + + void addBool(String name, bool value) => + args[name] = value ? "true" : "false"; +} diff --git a/lib/models/api_response.dart b/lib/models/api_response.dart new file mode 100644 index 0000000..227f207 --- /dev/null +++ b/lib/models/api_response.dart @@ -0,0 +1,16 @@ +import 'dart:convert'; + +/// API response +/// +/// @author Pierre HUBERT + +class APIResponse { + final int code; + final String content; + + const APIResponse(this.code, this.content) : assert(code != null); + + List getArray() => jsonDecode(this.content); + + Map getObject() => jsonDecode(this.content); +} diff --git a/lib/models/authentication_details.dart b/lib/models/authentication_details.dart new file mode 100644 index 0000000..5d38dc2 --- /dev/null +++ b/lib/models/authentication_details.dart @@ -0,0 +1,14 @@ +import 'package:meta/meta.dart'; + +/// Authentication details +/// +/// @author Pierre HUBERT + +class AuthenticationDetails { + final String email; + final String password; + + const AuthenticationDetails({@required this.email, @required this.password}) + : assert(email != null), + assert(password != null); +} diff --git a/lib/models/config.dart b/lib/models/config.dart new file mode 100644 index 0000000..249468b --- /dev/null +++ b/lib/models/config.dart @@ -0,0 +1,42 @@ +import 'package:meta/meta.dart'; + +/// Application configuration model +/// +/// @author Pierre HUBERT + +/// Configuration class +class Config { + final String apiServerName; + final String apiServerUri; + final bool apiServerSecure; + final String serviceName; + final String serviceToken; + + const Config( + {@required this.apiServerName, + @required this.apiServerUri, + @required this.apiServerSecure, + @required this.serviceName, + @required this.serviceToken}) + : assert(apiServerName != null), + assert(apiServerUri != null), + assert(apiServerSecure != null), + assert(serviceName != null), + assert(serviceToken != null); + + /// Get and set static configuration + static Config _config; + + static Config get() { + return _config; + } + + static void set(Config conf) { + _config = conf; + } +} + +/// Get the current configuration of the application +Config config() { + return Config.get(); +} diff --git a/lib/models/login_tokens.dart b/lib/models/login_tokens.dart new file mode 100644 index 0000000..67cc5e0 --- /dev/null +++ b/lib/models/login_tokens.dart @@ -0,0 +1,23 @@ +import 'dart:convert'; + +/// Login tokens model +/// +/// @author Pierre HUBERT + +class LoginTokens { + final String tokenOne; + final String tokenTwo; + + const LoginTokens(this.tokenOne, this.tokenTwo) + : assert(tokenOne != null), + assert(tokenTwo != null); + + LoginTokens.fromJSON(Map json) + : tokenOne = json["token_one"], + tokenTwo = json["token_two"]; + + @override + String toString() { + return jsonEncode({"token_one": tokenOne, "token_two": tokenTwo}); + } +} diff --git a/lib/ui/routes/home_route.dart b/lib/ui/routes/home_route.dart new file mode 100644 index 0000000..0ac6b96 --- /dev/null +++ b/lib/ui/routes/home_route.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +/// Main route of the application +/// +/// @author Pierre HUBERT + +class HomeRoute extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Text("Home route"); + } +} \ No newline at end of file diff --git a/lib/ui/login_route.dart b/lib/ui/routes/login_route.dart similarity index 56% rename from lib/ui/login_route.dart rename to lib/ui/routes/login_route.dart index 30f98e2..7e4bce5 100644 --- a/lib/ui/login_route.dart +++ b/lib/ui/routes/login_route.dart @@ -1,5 +1,9 @@ +import 'package:comunic/helpers/account_helper.dart'; +import 'package:comunic/models/authentication_details.dart'; +import 'package:comunic/ui/routes/home_route.dart'; import 'package:comunic/utils/input_utils.dart'; import 'package:comunic/utils/intl_utils.dart'; +import 'package:comunic/utils/ui_utils.dart'; import 'package:flutter/material.dart'; /// Login route @@ -14,6 +18,7 @@ class LoginRoute extends StatefulWidget { class _LoginRouteState extends State { String _currEmail; String _currPassword; + AuthResult _authResult; @override void initState() { @@ -35,26 +40,51 @@ class _LoginRouteState extends State { } /// Call this whenever the user request to perform login - void _submitForm() { + Future _submitForm(BuildContext context) async { + final loginResult = await AccountHelper().signIn( + AuthenticationDetails(email: _currEmail, password: _currPassword)); + if (loginResult == AuthResult.SUCCESS) + Navigator.pushReplacement( + context, MaterialPageRoute(builder: (b) => HomeRoute())); + else + setState(() { + _authResult = loginResult; + }); + } + + // Build error card + Widget _buildErrorCard() { + if (_authResult == null) + return null; + + //Determine the right message + final message = (_authResult == AuthResult.INVALID_CREDENTIALS ? tr( + "Invalid credentials!") : (_authResult == AuthResult.TOO_MANY_ATTEMPTS + ? tr("Too many unsuccessfull login attempts! Please try again later...") + : tr("A network error occured!"))); + + return buildErrorCard(message); } /// Build login form - Widget _buildLoginForm(){ + Widget _buildLoginForm() { return SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(8.0), child: Column( children: [ Text(tr("Please sign into your Comunic account: ")), + Container( + child: _buildErrorCard(), + ), //Email address TextField( keyboardType: TextInputType.emailAddress, decoration: InputDecoration( labelText: tr("Email address"), alignLabelWithHint: true, - errorText: - _currEmail.length > 0 && !validateEmail(_currEmail) + errorText: _currEmail.length > 0 && !validateEmail(_currEmail) ? tr("Invalid email address!") : null), onChanged: _emailChanged, @@ -72,7 +102,9 @@ class _LoginRouteState extends State { RaisedButton( child: Text(tr("Sign in")), - onPressed: !validateEmail(_currEmail) || _currPassword.length < 3 ? null : _submitForm, + onPressed: !validateEmail(_currEmail) || _currPassword.length < 3 + ? null + : () => _submitForm(context), ) ], ), @@ -83,10 +115,9 @@ class _LoginRouteState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: Text("Comunic"), - ), - body: _buildLoginForm() - ); + appBar: AppBar( + title: Text("Comunic"), + ), + body: _buildLoginForm()); } } diff --git a/lib/utils/ui_utils.dart b/lib/utils/ui_utils.dart new file mode 100644 index 0000000..db8b13d --- /dev/null +++ b/lib/utils/ui_utils.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +/// User interface utilities + +/// Build and return a full loading page +Widget buildLoadingPage() { + return Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); +} + +/// Build and return an error card +Widget buildErrorCard(String message) { + return Card( + elevation: 2.0, + color: Colors.red, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Icon( + Icons.error, + color: Colors.white, + ), + ), + Flexible( + child: Text( + message, + style: TextStyle(color: Colors.white), + maxLines: null, + ), + ) + ], + ), + ), + ); +} diff --git a/pubspec.lock b/pubspec.lock index 795d74c..5cc348c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -81,6 +81,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + url: "https://pub.dartlang.org" + source: hosted + version: "0.5.2" sky_engine: dependency: transitive description: flutter @@ -144,3 +151,4 @@ packages: version: "2.0.8" sdks: dart: ">=2.1.0 <3.0.0" + flutter: ">=0.1.4 <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 77fcdbd..76cc0d4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,9 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^0.1.2 + # Preferences are useful for a lot of things (ex: login tokens) + shared_preferences: ^0.5.2 + dev_dependencies: flutter_test: sdk: flutter