Step 6. Implement the UI in Flutter
Review how the application renders the home view.
In Android Studio, navigate to the Flutter project, flutter_todo_app > java/main.dart.
Open up the second file in the above sequence, the java/main.dart
file, and notice the following:
-
The use of
import 'package:flutter/material.dart';
from the Flutter library. -
The
TodoApp
class extendingStatefulWidget
. -
The
_TodoAppState
class extendingState<TodoApp>
. -
Building the UI for the navigation bar.
import 'package:flutter/material.dart'; import 'package:flutter_todo_app/home.dart'; import 'package:flutter_todo_app/login.dart'; import 'package:flutter_todo_app/todolist.dart'; void main() => runApp( new TodoApp(), ); class TodoApp extends StatefulWidget { @override _TodoAppState createState() => new _TodoAppState(); } class _TodoAppState extends State<TodoApp> { int _selectedIndex = 0; final _pageOptions = [ HomePage(), LoginPage(), TodoList(), ]; void _onItemTapped(int index) { setState(() { _selectedIndex = index; }); } @override Widget build(BuildContext context) { return new MaterialApp( home: Scaffold( body: _pageOptions[_selectedIndex], bottomNavigationBar: BottomNavigationBar( items: const <BottomNavigationBarItem>[ BottomNavigationBarItem( icon: Icon(Icons.home), label: 'Home', ), BottomNavigationBarItem( icon: Icon(Icons.vpn_key), label: 'Sign In', ), ], currentIndex: _selectedIndex, selectedItemColor: Colors.blueAccent[800], onTap: _onItemTapped, backgroundColor: Colors.grey[200], ) ), ); } }
Flutter uses something called MethodChannel
to communicate between Flutter and the Native layer.
In this application we will define a MethodChannel
with the following identifier: 'forgerock.com/SampleBridge'
.
The same identifier will be used in the iOS FRSampleBridge so that the two layers communicate and pass information.
To initialize the Ping SDK when the log in view first loads, we call the frStart()
method on the bridge code.
It’s important to initialize the SDK as early as possible. Call this initialization step, so it resolves before any other native SDK methods can be used. |
Building the login view
Navigate to the app’s login view within the Simulator. You should see an empty screen with a button, since the app doesn’t have the data needed to render the form. To render the correct form, retrieve the initial data from the server. This is our first task.
Since most of the action is taking place in
flutter_todo_app/Java/login.dart
, open it and add the following:
-
Import
FRNode.dart
from the Dart helper classes provided for improved ergonomics for handling callbacks:import 'package:flutter_todo_app/FRNode.dart';
-
If not already there, import
async
,convert
,scheduler
,services
from theflutter
package. Add the following:import 'dart:async'; import 'dart:convert'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart';
-
Create a static reference for the method channel
MethodChannel('forgerock.com/SampleBridge')
-
Override the
initState
Flutter lifecycle method and initialize the SDK.class _LoginPageState extends State<LoginPage> { + static const platform = MethodChannel('forgerock.com/SampleBridge'); //Method channel as defined in the native Bridge code @@ collapsed @@ //Lifecycle Methods + @override + void initState() { + super.initState(); + SchedulerBinding.instance?.addPostFrameCallback((_) => { + //After creating the first controller that uses the SDK, call the 'frAuthStart' method to initialize the native SDKs. + _startSDK() + }); + } // SDK Calls - Note the promise type responses. Handle errors on the UI layer as required Future<void> _startSDK() async { + String response; + try { + + //Start the SDK. Call the frAuthStart channel method to initialise the native SDKs + final String result = await platform.invokeMethod('frAuthStart'); + response = 'SDK Started'; + _login(); + } on PlatformException catch (e) { + response = "SDK Start Failed: '${e.message}'."; + } } @@ collapsed @@
To develop the login functionality, we first need to use the login()
method from the bridge code
to get the first set of callbacks, and then render the form appropriately.
This login()
method is an asynchronous method.
Let’s get started!
Compose the data gathering process using the following:
-
After the SDK initialization is complete, call the
_login()
method. -
Use the
platform
reference to call the Bridge login methodplatform.invokeMethod('login')
. -
Parse the response and call
_handleNode()
method. -
Handle any errors that might be returned from the
Bridge
.@@ collapsed @@ Future<void> _login() async { + try { + //Call the default login tree. + final String result = await platform.invokeMethod('login'); + Map<String, dynamic> frNodeMap = jsonDecode(result); + var frNode = FRNode.fromJson(frNodeMap); + currentNode = frNode; + + //Upon completion, a node with callbacks will be returned, handle that node and present the callbacks to UI as needed. + _handleNode(frNode); + } on PlatformException catch (e) { + debugPrint('SDK Error: $e'); + Navigator.pop(context); + } }
The above code is expected to return either a Node
with a set of Callback
objects, or a success/error message.
We need to handle any exceptions thrown from the bridge on the catch block.
Typically, when we begin the authentication journey/tree, this returns a Node
.
Using the FRNode
helper object, we parse the result in a native Flutter FRNode
object.
In the next step we are going to "handle" this node, and produce our UI.
@@ collapsed @@
// Handling methods
void _handleNode(FRNode frNode) {
+ // Go through the node callbacks and present the UI fields as needed. To determine the required UI element, check the callback type.
+ frNode.callbacks.forEach((frCallback) {
+ final controller = TextEditingController();
+ final field = TextField(
+ controller: controller,
+ obscureText: frCallback.type == "PasswordCallback", // If the callback type is 'PasswordCallback', make this a 'secure' textField.
+ enableSuggestions: false,
+ autocorrect: false,
+ decoration: InputDecoration(
+ border: OutlineInputBorder(),
+ labelText: frCallback.output[0].value,
+ ),
+ );
+ setState(() {
+ _controllers.add(controller);
+ _fields.add(field);
+ });
+ });
}
The _handleNode()
method focuses on the callbacks
property.
This property contains instructions about what to render to collect user input.
The previous code processes the Node callbacks and generates two TextField
objects:
-
A
TextField
for the username. -
A
TextField
for the password.
Use the frCallback.type
to differentiate between the two TextField
objects and obscure the text of each TextField
.
Next, add the TextField
objects to the List
and create the accompanying TextEditingControllers.
Run the app again, and you should see a dynamic form that reacts to the callbacks returned from our initial call to ForgeRock.
Submitting the login form
Since a form that can’t submit anything isn’t very useful,
we’ll now handle the submission of the user input values to ForgeRock.
Continuing in login.dart
, edit the current _okButton
element,
adding an onPressed
handler calling the _next()
function.
This function should do the following:
-
Go through the
_controllers
array to capture the values of the form elements. -
Update the
Node callbacks
with those values. -
Submit the results to ForgeRock.
-
Check the response for a
LoginSuccess
message, or if a new node is returned, handle this in a similar way and resubmit the user inputs as needed. -
Handle errors with a generic failure message.
@@ collapsed @@ Widget _okButton() { return Container( color: Colors.transparent, width: MediaQuery.of(context).size.width, margin: EdgeInsets.all(15.0), height: 60, child: TextButton( style: ButtonStyle(backgroundColor: MaterialStateProperty.all(Colors.blue)), onPressed: () async { showAlertDialog(context); + _next(); }, child: Text( "Sign in", style: TextStyle(color: Colors.white), ), ), ); } @@ collapsed @@ Future<void> _next() async { // Capture the User Inputs from the UI, populate the currentNode callbacks and submit back to {am_name} + currentNode.callbacks.asMap().forEach((index, frCallback) { + _controllers.asMap().forEach((controllerIndex, controller) { + if (controllerIndex == index) { + frCallback.input[0].value = controller.text; + } + }); + }); + String jsonResponse = jsonEncode(currentNode); + try { + // Call the SDK next method, to submit the User Inputs to {am_name}. This will return the next Node or a Success/Failure + String result = await platform.invokeMethod('next', jsonResponse); + Navigator.pop(context); + Map<String, dynamic> response = jsonDecode(result); + if (response["type"] == "LoginSuccess") { + _navigateToNextScreen(context); + } else { + //If a new node is returned, handle this in a similar way and resubmit the user inputs as needed. + Map<String, dynamic> frNodeMap = jsonDecode(result); + var frNode = FRNode.fromJson(frNodeMap); + currentNode = frNode; + _handleNode(frNode); + } + } catch (e) { + Navigator.pop(context); + debugPrint('SDK Error: $e'); + } } @@ collapsed @@
After the app refreshes, use the test user to login. If successful, you should see a success message. Congratulations, you are now able to authenticate users!
What’s more, you can verify the authentication details by going to the Xcode or Android Studio log,
and observing the result of the last call to the server.
It should have a type of LoginSuccess
with token information.
Handling the user provided values
You may ask, "How did the user’s input values get added to the Node
object?"
Let’s take a look at the component for handling the user input submission.
Notice how we loop through the Node Callbacks
and the _controllers
array.
Each input is set on the frCallback.input[0].value
, and then we call FRSampleBridge.next()
method.
@@ collapsed @@
// Capture the User Inputs from the UI, populate the currentNode callbacks and submit back to {am_name}
currentNode.callbacks.asMap().forEach((index, frCallback) {
_controllers.asMap().forEach((controllerIndex, controller) {
if (controllerIndex == index) {
frCallback.input[0].value = controller.text;
}
});
});
String jsonResponse = jsonEncode(currentNode);
@@ collapsed @@
try {
// Call the SDK next method, to submit the User Inputs to {am_name}. This will return the next Node or a Success/Failure
String result = await platform.invokeMethod('next', jsonResponse);
@@ collapsed @@
} catch (e) {
Navigator.pop(context);
debugPrint('SDK Error: $e');
}
There are two important items to focus on regarding the FRCallback
object.
callback.type
-
Retrieves the call back
type
so that can identify how to present the callback in the UI. callback.input
-
The input array that contains the inputs that you need to set the values for.
Since the NameCallback
and PasswordCallback
only have one input,
you can set the value of them by calling frCallback.input[0].value = controller.text;
.
Some other callbacks might contain multiple inputs, so some extra code will be required to set the values of those.
Each callback type has its own collection of inputs and outputs.
Those are exposed as arrays that the developer can loop through and act upon.
Many callbacks have common base objects in iOS and Android, like the SingleValueCallback
,
but appear as different types NameCallback
or PasswordCallback
to allow for easier differentiation in the UI layer.
You can find a full list of the supported callbacks of the SDKs here.
Redirecting to the TodoList screen and requesting user info
Now that the user can log in, let’s go one step further and redirect to the TodoList screen.
After we get the LoginSccess
message we can call the _navigateToNextScreen()
method.
This will navigate to the TodoList
class.
When the TodoList
initializes, we want to request information about the authenticated user
to display their name and other information.
We will now utilize the existing FRAuthSampleBridge.getUserInfo()
method already included in the bridge code.
Let’s do a little setup before we make the request to the server:
-
Override the
initState()
method in the_TodoListState
class intodolist.dart
. -
Create a
SchedulerBinding.instance?.addPostFrameCallback
to execute some code when the state is loaded. -
Call
_getUserInfo()
.@@ collapsed @@ //Lifecycle methods + @override + void initState() { + super.initState(); + SchedulerBinding.instance?.addPostFrameCallback((_) => { + //Calling the userinfo endpoint is going to give use some user profile information to enrich our UI. Additionally, verifies that we have a valid access token. + _getUserInfo() + }); + } @@ collapsed @@
With the setup complete, implement the request to your server for the user’s information.
Within this empty _getUserInfo()
, add an async
function
to make that call FRAuthSampleBridge.getUserInfo()
and parse the response.
@@ collapsed @@
Future<void> _getUserInfo() async {
showAlertDialog(context);
String response;
+ try {
+ final String result = await platform.invokeMethod('getUserInfo');
+ Map<String, dynamic> userInfoMap = jsonDecode(result);
+ response = result;
+ header = userInfoMap["name"];
+ subtitle = userInfoMap["email"];
+ Navigator.pop(context);
+ setState(() {
+ _getTodos();
+ });
+ } on PlatformException catch (e) {
+ response = "SDK Start Failed: '${e.message}'.";
+ Navigator.pop(context);
+ }
+ debugPrint('SDK: $response');
}
@@ collapsed @@
In the code above, we collected the user information and set the name and email of the user in some variables.
In addition to updating the user info, we will call the _getTodos()
method in order to retrieve ToDos from the server.
Notice that we use the setState()
function.
This ensures that our UI is updated based on the newly received information.
When you test this in the Simulator, completing a successful authentication results in the home screen being rendered with a success message. The user’s name and email are included for visual validation. You can also view the console in Xcode and see more complete logs.
Adding logout functionality
Clicking the Sign Out button results in creating and rendering an alert view asking you if you are sure you want to log out with two options (yes/no). Clicking yes does nothing at the moment. We will now implement that missing logic.
To add the logic into the view to call this new Swift method:
-
Open the
todolist.dart
file, and add the following:@@ collapsed @@ TextButton( child: const Text('Yes'), + onPressed: () { + Navigator.of(context).pop(); + _logout(); + }, ), @@ collapsed @@ Future<void> _logout() async { + final String result = await platform.invokeMethod('logout'); + _navigateToNextScreen(context); }
-
Revisit the app within the Simulator, and tap the Sign Out button.
This time around when clicking Yes will dispose of the alert and log you out, returning you back to the log in screen.
If you tap No, you will return to the main list screen.