Skip to main content

Building an App with flutter

· 15 min read
Prad Nukala
Nicholas Tindle

Building apps has never been easier, especially today with tools like Flutter. You can build apps in minutes.

Let's Build a simple Flutter To Do app.

To start, make sure you have Flutter installed. If you're on MacOS, you can run brew install --cask flutter to install it.
Also, make sure you have the latest version of Xcode installed.

tip

You can check that Flutter successfully installed with Flutter doctor:

terminal
flutter doctor

If you run into installation issues, we suggest checking out stack overflow!

tip

You can open this repo in GitPod to follow along with this tutorial.

Contribute with Gitpod

Creating a new project

To create a new project, run the following command:

terminal
flutter create todo_app

Now you can open the project in your favorite editor. Then, we will run it and make sure it works.

terminal
cd todo_app
flutter run

You should see a screen like this: Flutter Demo

Note: this screenshot is from MacOS, yours may look different depending on the platform you're using.

Adding motor_flutter

Next, we'll need to add the motor_flutter package to our project. To do this, we will use the flutter pub add command.

terminal
flutter pub add motor_flutter

Then, checking the pubspec.yaml file, you should see the following:

pubspec.yaml
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.2
motor_flutter: ^0.8.2

Note, your version may be different depending on when you read this. Check the motor-flutter pub.dev page for the latest version.

To finish setting up on iOS, we need to make the following changes:

  1. Open ios/Podfile and add the following line to the top of the file:

    ios/Podfile
    platform :ios, '13.0'

    Note: the 13.0 is the minimum version of iOS that motor_flutter supports.

  2. Remove the use_frameworks! line from the Podfile.

    It should look like this:

    ios/Podfile
    # ...
    target 'Runner' do
    # Remove this next line
    use_frameworks!
    # ...
    end
    # ...

Initializing motor_flutter

Now that we have the package installed, we can initialize it. To do this, we want to import it in our main.dart file.

lib/main.dart
import 'package:motor_flutter/motor_flutter.dart';

Then, we can initialize it in the main function:

lib/main.dart
void main() {
MotorFlutter.init();
runApp(MyApp());
}

You may notice it takes a while to load, keep reading, theres a problem with the above and it never will load.

I prefer to use GetX to manage my state, so let's will make a few changes first.

  1. Add the get package to our project:

    terminal
    flutter pub add get
  2. Add the get_storage package to our project:

    terminal
    flutter pub add get_storage
  3. Import the get and get_storage package in our main.dart file:

    lib/main.dart
    import 'package:get/get.dart';
    import 'package:get_storage/get_storage.dart';

  4. Change the MyApp class to return GetMaterialApp instead of MaterialApp:

    lib/main.dart
    class MyApp extends StatelessWidget {
    const MyApp({super.key});

    // This widget is the root of your application.

    Widget build(BuildContext context) {
    // This next line is the only change
    return GetMaterialApp(
    title: 'Flutter Demo',
    theme: ThemeData(
    // This is ...
    primarySwatch: Colors.blue,
    ),
    home: const LoginPage(title: "Login here"),
    );
    }
    }

Fixing the initialization

  1. Change the main function to use Future<void> instead of void:

    This allows us to use async and await in the function.

    lib/main.dart
    Future<void> main() async {
    // ...
    }
  2. Initialize the MotorFlutter class in the main function using Get:

    lib/main.dart
    Future<void> main() async {
    // Don't worry about this line just yet
    WidgetsFlutterBinding.ensureInitialized();
    await GetStorage.init(); // <-- Initializes the GetStorage Package
    await MotorFlutter.init(); // <-- Initializes the MotorFlutter Package
    runApp(const MyApp()); // <-- Added const here to keep vs code happy
    }

Adding a login page

Now that we have motor initialized, we can add a login page. To do this, we will make a new page and add a button to navigate to it.

  1. Create a new file called login_page.dart in the lib folder.

  2. Change main.dart to import the new file:

    lib/main.dart
    import 'package:motor_flutter/motor_flutter.dart';
    import 'login_page.dart'; // <-- this is the new line
  3. Swap the home of the application to your login page.

    lib/main.dart
      class MyApp extends StatelessWidget {
    const MyApp({super.key});

    // This widget is the root of your application.

    Widget build(BuildContext context) {
    return GetMaterialApp(
    title: 'Flutter Demo',
    theme: ThemeData(
    // This is the theme ...
    primarySwatch: Colors.blue,
    ),
    home: const LoginPage(title: 'Flutter Demo Home Page'), // <-- this is the changed line
    );
    }
    }
  4. Add the following UI code to the file.

tip

This site has a neat feature where you can hover over the code block and a copy button will appear.

A line wrap button also appears. It would be wise to use it here.

lib/login_page.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';

import 'package:motor_flutter/motor_flutter.dart';

class LoginPage extends StatefulWidget {
const LoginPage({super.key, required this.title});

final String title;


State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
TextEditingController addressController = TextEditingController();
TextEditingController passwordController = TextEditingController();

void _login() {
// TODO: Add login logic
debugPrint(addressController.text);
debugPrint(passwordController.text);
}


Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text("Login Page"),
),
body: SingleChildScrollView(
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 60.0),
child: Center(
child: Container(
width: 200,
height: 150,
padding: const EdgeInsets.all(25),
child: const Text("Please Login",
style: TextStyle(fontSize: 20))),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: TextField(
controller: addressController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'SNR Address',
hintText: 'Enter valid Address as snr1ioiaksldjfenkjaof29oi...'),
),
),
Padding(
padding: const EdgeInsets.only(
left: 15.0, right: 15.0, top: 15, bottom: 0),
child: TextField(
controller: passwordController,
obscureText: true,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Password',
hintText: 'Enter your secure password'),
),
),
Container(
height: 50,
width: 250,
margin: const EdgeInsets.only(top: 20),
decoration: BoxDecoration(
color: Colors.blue, borderRadius: BorderRadius.circular(20)),
child: ElevatedButton(
onPressed: () => _login(),
child: const Text(
'Login',
style: TextStyle(color: Colors.white, fontSize: 25),
),
),
),
Container(
height: 50,
width: 200,
margin: const EdgeInsets.only(top: 20),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey,
),
onPressed: () => Get.defaultDialog(
title:
"You pressed the Create Account button, but it's not implemented yet. We will swap it with the Continue on Sonr button"),
child: const Text(
'Continue on Sonr',
style: TextStyle(color: Colors.white, fontSize: 18),
),
),
),
],
),
),
);
}
}

void debugPrint(Object? message) {
if (kDebugMode) {
print(message);
}
}

It should look something like this: Login Page

If you attempt to use the Login button, you will see something like the following in the console:

flutter log output
flutter: adsfs
flutter: aasdfasdf

Before we can login though, we need to create an account. We will do this in the next section.

First, let's walk through the code above.

Login Page Widget

lib/login_page.dart
class LoginPage extends StatefulWidget {
const LoginPage({super.key, required this.title});

final String title;


State<LoginPage> createState() => _LoginPageState();
}

All this is doing is creating a page and attaching a state to it.

Login Page State

lib/login_page.dart
class _LoginPageState extends State<LoginPage> {
TextEditingController addressController = TextEditingController();
TextEditingController passwordController = TextEditingController();

void _login() {
// TODO: Add login logic
print(addressController.text);
print(passwordController.text);

}
//...
}

This is where we store the state of the login page. We also defined a couple functions to help us out.

Login Function

lib/login_page.dart
  void _login() {
// TODO: Add login logic
print(addressController.text);
print(passwordController.text);
}

This is the function that will be called when the user presses the login button. It will print the text in the text fields to the console. Later on, we will replace this with the actual login logic.

build

lib/login_page.dart

Widget build(BuildContext context) {
// 1. This is the scaffold that will hold the page.
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text("Login Page"),
),
// 2. This is the body of the page.
// All of our content will go within it.
// The visible item is the "Please Login" text.
body: SingleChildScrollView(
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 60.0),
child: Center(
child: Container(
width: 200,
height: 150,
padding: const EdgeInsets.all(25),
child: const Text("Please Login",
style: TextStyle(fontSize: 20))),
),
),
// 3. This is the text field for the SNR Address.
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: TextField(
controller: addressController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'SNR Address',
hintText: 'Enter valid Address as snr1ioiaksldjfenkjaof29oi...'),
),
),
// 4. This is the text field for the password.
Padding(
padding: const EdgeInsets.only(
left: 15.0, right: 15.0, top: 15, bottom: 0),
child: TextField(
controller: passwordController,
obscureText: true,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Password',
hintText: 'Enter your secure password'),
),
),
// 5. This is the login button.
// This is where we call the _login function.
Container(
height: 50,
width: 250,
margin: const EdgeInsets.only(top: 20),
decoration: BoxDecoration(
color: Colors.blue, borderRadius: BorderRadius.circular(20)),
child: ElevatedButton(
onPressed: () => _login(),
child: const Text(
'Login',
style: TextStyle(color: Colors.white, fontSize: 25),
),
),
),
// 6. This is the create account button.
// The Create Account button has a dialog attached to it.
// This is just a placeholder for now.
// We will swap this to the Continue on Sonr button.
Container(
height: 50,
width: 200,
margin: const EdgeInsets.only(top: 20),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey,
),
onPressed: () => Get.defaultDialog(
title:
"You pressed the Create Account button, but it's not implemented yet"),
child: const Text(
'Create Account',
style: TextStyle(color: Colors.white, fontSize: 18),
),
),
),
],
),
),
);
}

This is the build function. It's where we do the bulk of page layout. I've broken it down into numbered sections to make it easier to follow. You should be able to match each of the sections to the image above.

Creating an account

Now that we have a login page, we need to be able to create an account. To do this, we will utilize the ContinueOnSonr Button that exists within the motor_flutter package.

  1. Swap the register button with the ContinueOnSonr button.
lib/login_page.dart
Container(
margin: const EdgeInsets.only(top: 20),
child: ContinueOnSonrButton(
onSuccess: (ai) {
if (ai == null) {
throw Exception("AuthInfo is null");
}
debugPrint("Address: ${ai.address}");
debugPrint("deviceSharedKey: ${ai.aesDscKey}");
debugPrint("passwordSecuredKey: ${ai.aesPskKey}");

GetStorage().write("address", ai.address);
GetStorage().write("deviceSharedKey", ai.aesDscKey);
GetStorage().write("passwordSecuredKey", ai.aesPskKey);
},
onError: (err) {
Get.snackbar("Error", err.toString());
},
),
)
  1. The user doesn't know their address yet, so we should pull it from memory and prefill it for them.
    lib/login_page.dart
    class _LoginPageState extends State<LoginPage> {
    TextEditingController addressController =
    TextEditingController(text: GetStorage().read("address"));
    TextEditingController passwordController = TextEditingController();
    //...
    }
    GetStorage's read function will return the value stored in the box. If that value isn't there, it will return null and the TextController will be empty.

Logging in

Now that we have a login page and a way to create an account, we need to be able to log in. We just have to fill out the _login function from earlier and we should be good to go.

Calling the .login function on MotorFlutter

The MotorFlutter controller has a .login function that we can call to log in. We just need to pass it the address and password.

lib/login_page.dart
void _login() async {
final res = await Get.find<MotorFlutter>().login(
password: passwordController.text,
address: addressController.text,
pskKey: GetStorage().read('passwordSecuredKey').cast<int>(),
dscKey: GetStorage().read('deviceSharedKey').cast<int>());
if (res != null) {
if (Get.find<MotorFlutter>().authorized.isTrue) {
Get.snackbar("Success", "Login successful");
Get.offAll(() => const MyHomePage(title: 'Flutter Demo Home Page'));
} else {
Get.snackbar('Error', 'Login failed');
}
} else {
Get.snackbar("Login", "Login failed");
}
}

Handling the response

The login function returns a Future<WhoIs>. A WhoIs is a data structure that contains information about the user. The first thing we should do is check if the response is null. If it is, we can assume that the login failed. If it isn't null, we can check if the authorized property is true. That will let us know the login was successful. If it was successful, we should push the user to the home page so they can use the app.

tip

If you are going to make your own app based off this tutorial, heres where things get specific to the todo app.

You can just copy main.dart and login_page.dart from the todo app and build from there too.

Preparing the home page

Now that we have a login page, we need to create a home page. This is where we will be able to create To Do items and view them. This home page will be the main page of the app, and home to the list of To Do items. The first step of the page is defining what a To Do item is.

Creating the data models

We need to create a data model for our To Do items. There's a few ways to create the schemas we need.

  1. Speedway
  2. Speedway-CLI (See the other blog in the sidebar)
  3. Manually through MotorFlutter

We will be using the third option. I would not recommend this in production, as it is very easy to make dupliate schemas.

We are going to trigger it on the home page. In main.dart, we will add a function called _createSchema that will create the schema for us.

lib/main.dart
//...
Future<void> _createSchema() async {
if (!Get.isRegistered<Schema>(tag: "taskSchema")) {
CreateSchemaResponse resp = await MotorFlutter.to.publishSchema("task", {
'task': SchemaFieldKind(kind: Kind.STRING),
}, metadata: {
'use': 'github.com/ntindle/todo_app',
});

print("Schema created: $resp");
final schema = resp.whatIs.schema;
print("Schema: $schema");
Get.put(schema, tag: "taskSchema");
} else {
print(
"Schema already exists ${Get.find<Schema>(tag: "taskSchema").did}");
}
}

This function will check if the schema is already registered and stored in memory, and if not, it will create it. Do not do this in production, as it is very easy to create duplicate schemas.

You can see above our data model is very simple. It only has one field, task, which is a string. We also have a label, also named task, and a metadata field use that we stored the name of the repo in. You can add any metadata you want to your schemas.

When you create a schema, you can add a callback that is triggered when the schema is created. This is where we actually register the Schema object in memory. Schema are used to create new instances of the schema using newDocument.

We want to call our schema creator from our home page. I'm going to reuse the existing + button already on the screen to do so. Replace the _incrementCounter function with the following:

lib/main.dart
void _incrementCounter() async {
await _createSchema();
Get.to(const TodoPage(title: 'Add item'));
}
tip

Running into a weird bug you don't understand?

Try stopping the app and then uninstalling it from your simulator. Make sure to delete the app from your simulator, not just stop it.

Now, when you click the + button, it will create/check the schema and push you to the todo page. Lets build that out now, and come back to the home page once we have some data to display.

Adding a To Do Page

Now that we have a schema, we can create a new To Do item. We will be using the newDocument function from MotorFlutter to create a new document. Create a new file todo_page.dart and add the following code:

lib/todo_page.dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';

import 'package:motor_flutter/motor_flutter.dart';
import 'package:todo_app/main.dart';

class TodoPage extends StatefulWidget {
const TodoPage({super.key, required this.title});

final String title;


State<TodoPage> createState() => _TodoPageState();
}

class _TodoPageState extends State<TodoPage> {
GetStorage box = GetStorage();
TextEditingController todoItemController = TextEditingController();

// Create your new documents here
void _addItem() {}


Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text("Add item"),
),
body: SingleChildScrollView(
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 60.0),
child: Center(
child: Container(
width: 200,
height: 150,
padding: const EdgeInsets.all(25),
child: const Text("Add a todo item",
style: TextStyle(fontSize: 20))),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: TextField(
controller: todoItemController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Your Todo Item',
hintText: 'Enter your todo item'),
),
),
Container(
height: 50,
width: 250,
margin: const EdgeInsets.only(top: 20),
decoration: BoxDecoration(
color: Colors.blue, borderRadius: BorderRadius.circular(20)),
child: ElevatedButton(
onPressed: () => _addItem(),
child: const Text(
'Add',
style: TextStyle(color: Colors.white, fontSize: 25),
),
),
),
],
),
),
);
}
}

This page should be pretty familiar to you by now. Its a simple page with a text field and a button to add a new item.

We can't forget to add the import to main.dart:

lib/main.dart
import 'package:todo_app/todo_page.dart';

Creating a new document

Now, we can add the _addItem function.

lib/todo_page.dart
void _addItem() async {
final String task = todoItemController.text;
final doc = Get.find<Schema>(tag: 'taskSchema').newDocument();
doc.set<String>('task', task);
SchemaDocument? resp = await doc.upload(task);
if (resp != null) {
Get.snackbar('Success', 'Item added');
print(resp.did);
todoItemController.clear();
} else {
Get.snackbar('Error', 'Item not added');
}
}

This function pulls the Schema taskSchema from memory, creates a new document, sets the task field to the text in the text field, and then uploads it. If the upload is successful, we get a SchemaDocument back, which we can use to get the DID of the new document. We then clear the text field and show a success message. If the upload fails, we show an error message and do not clear the text field.