You will also learn about the following:
This is what we'll be accomplishing in this codelab: users will be able to log in, and under their credentials, they will have bank accounts set up. They can view their account details, such as the type of account, balance, account number, etc. They can only get to this page only if they are logged in, hence the reason why we're putting behind the authentication workflow.
The following is a schematics view of what we'll be tackling for this page widget:
Let's proceed!
Running this on DartPad will make us land always on this page (we'll only have it like while we develop this page):
And with that in place, let's set up Firebase Cloud Firestore and the data we'll be consuming. Launch Firebase Console - see you there!
Cloud Firestore Firestore is a flexible, scalable NoSQL cloud database to store and sync data. It keeps your data in sync across client apps through realtime listeners and offers offline support so you can build responsive apps that work regardless of network latency or Internet connectivity. It is built on top of Google's storage infrastructure, with multi-regional support and strong consistency.
Firestore stores data within "documents", which are contained within "collections". Documents can also contain nested collections, thus allowing you to build hierarchies to store related data and easily retrieve the data you need using expressive queries. All queries scale with the size of your result set (note: not your data set), so your app is ready to scale from day one.
Let's fire up our Firebase Console, and right at the homepage, use the left navigation to locate the Firestore Database link. The Cloud Firestore page shows. Click on Create database:
A dialog appears to set the security rules for your database. Make sure you select the option Start in Test Mode which means after 30 days you won't be able to access the data in Firebase unless you update the rules. We'll show this later in the codelab. Click Next:
Next step asks you you define the location where your Cloud Firestore database will be provisioned; keep the default (us-central). Click Enable to proceed:
Firebase then provisions your database for a few seconds:
And then Cloud Firestore is ready to go! Now, let's add some data to it, shall we?
We mentioned earlier the concept of documents and collections which are the core data structures in which Firestore organizes data.
Before you proceed, we need to get some information from one of our test users: their unique authenticated user id, which we'll use to uniquely associate the bank account information to them.
Go to the Authentication / Users tab, and copy the User UID next to their email (in our case, client@gmail.com - we'll continue using this account):
With that capture. Let's get back to our Firestore Database.
You always start with a collection in Firestore, which will hold one or more documents. Let's start by adding our first collection, called accounts. Click on Start Collection:
In the dialog that appears, type the name of your collection (accounts); hit Next:
At this point you are required to add your first document, as collections are nothing without documents. Collections are nothing more than containers of documents. As the unique identifier for our first document, let's use the UID we grabbed earlier. Hit Save for now:
Hooray! We have our first collection (accounts) and our first document (referenced by a unique UID).
So far this document doesn't contain anything. Let's make it more interesting and add a nested collection to this document. Make sure the document is selected (by clicking on the middle column accounts and selecting the document ID of the desired document). On the third column, click on Start Collection:
Name this nested collection user_accounts. Click Next:
Just like before, while creating either a root or a nested collection, you are required to add a first document. Here we'll make use of the Auto-ID feature to generate a random ID for our first document.
Once the auto-id is populated, it is time to add some fields to this document. Fields are nothing more than key-value pair entries in a document, and they can be one of many times (i.e. string, number, boolean, map, array (different from a collection), etc.). Feel free to explore on your own; we'll explore a few of those as we make progress.
We want to create a unique document that will represent a bank account, so the minimum amount of information we will need is a balance (usually a number), a bank account (usually a string), and a type of account (usually a string as well).
Add the following fields, using the plus (+) underneath each field to add additional ones:
Your document should look like this:
Verify, then hit Save. Your newly created document should look like this:
Now that we are experts, let's add a second document to the user_accounts collection. Under the user_accounts collection (middle column), click on Add Document - same dialog will appear. Add an auto-generated ID and add the following fields:
Verify that your fields look the same as below and click Save:
Our two documents are in place within the nested collection called user_accounts, inside a document referenced by a unique ID (the logged-in user UID) within a root collection called accounts. Your user_accounts nested collection should look like this:
With our database pre-populated with some data, let's do some querying from Flutter and the Firebase SDK!
We're ready to start pulling data from Firestore, but first we need to set a few things up, such as creating the model that will encapsulate the data about the bank account. For this we'll create a model class called Account, which we'll use to map the data coming from Firebase into a strongly-typed Dart model.
Create a class called Account, with the following fields and corresponding constructor parameters:
Also create a factory method to map the incoming JSON structure from Firebase (as a Map<String, dynamic>), and takes both the Map as well as the unique document's ID.
Your class should look like this:
Now that we know how we will map and model our data within Flutter, is the turn to create a wrapper around the data retrieval functionality, which we'll conveniently encapsulate within a provided service using the Provider pattern, which we'll call FlutterBankService.
In our DartPad environment, create a new class called FlutterBankService. Make it extend ChangeNotifier since later on we'll make it so listeners get notified of its changes:
Create a method called getAccounts, which takes a BuildContext as a parameter and will conveniently return a list of Account objects (List<Account>) wrapped inside a Future (since this call also will be asynchronous and we need to wait on it):
Now you ask: why are we passing the BuildContext into this method? We'll use to to fetch another service within this service - in our case, we need to retrieve a document from Firebase using as a reference the logged-in user's unique ID (hence why we stored the document using the unique UID as the association). We'll fetch the LoginService service using the injected context and fetching it out of the Provider, then getting the user ID using the convenient method of the LoginService called getUserId():
Create a local variable that will hold the list of accounts we fetch from Firebase before returning them to the user:
Instead of doing await/async on fetching the data from Firebase, this time I'll use a Completer and use the callback-registration approach, since I want to introduce a small delay before returning the data to the user (wow, Firebase is so fast fetching that I want to introduce a delay on purpose!), since I want to show a spinning indicator briefly to improve the user experience.
A Completer allows you to create Futures from scratch and for situations when you have callback-based API calls (like using .then()) or when you want to delay the completing of the Future to a later moment:
Then create an instance of Completer type of List>Account< called accountsCompleter since when we return the Future instance, we will complete it with an object of type List>Account<:
Now, let's fetch the data.
We're ready to query all documents associated to our logged in user (via their unique UID) inside the accounts collection, and then once the matched document by UID is found, grab all documents from the nested collection user_accounts. We'll fetch the data using a one-time read approach; later on we'll try the real-time approach. In the one-time read, data is fetched only once, using the .get() call:
Hook up a callback to the end of the get() method using the .then() for when the Future returned completes. Inside the callback, capture the result of the collection in a QuerySnapshot object that we are calling collection, like this:
Inside the callback, loop through the document references returned via the QuerySnapshot by pulling all document references (via the .docs property of the QuerySnapshot). Get the data out of each document reference (via the .data() call) and cast it to a **Map≶String, dynamic>).
Now, we need to map this data to a strongly-type model (our Account model created earlier) via its factory method .fromJson. Feed both the data and document it this method which should return an instance of Acount. Push this instance to our accounts collection.
After mapping and collecting all values, introduce a small delay using Future's utility method delayed and add a 2-second delay, then complete the Future generated by the completer by calling accountsCompleter.complete, passing the already populated collection:
Wrap this method up by returning a Future out of the Completer:
What this will do is: we'll return a Future from the Completer immediately, to the user. The user will await on this Future while we fetch the data from Firebase; once the data is ready (mapped and collected), we notify them through that same Future via the Completer's .complete call, passing the same type of data this method is returning via the method signature (Future≶List≶Account>>).
We're ready to take this for a spin! Let's go to our page FlutterBankMain and add the widgets that will consume this data.
Now that we've mapped the Firestore data to a more consumable type by our app, let's build our FlutterBankMain page so we can display the data.
Start by setting the Scaffold's background color to white; add an AppBar widget to the Scaffold with the following properties:
Your code should look like this:
Run it on DartPad to start seeing how's coming along:
We want to lay out our items in a column fashion, so let's start our structure by replacing the existing Center widget from the Scaffold's body, and adding a Container with some padding for breathing room, then as its children we'll add a Column widget:
As the first child of this Column, for the title, add a Row with an Icon with size of 30px (Icons.account_balance_wallet), some spacing and a Text widget with the text "My Accounts", font size 20 - both with the mainThemeColor:
Ending up looking like this once you run it on DartPad:
Add some spacing right underneath it using a SizedBox, 20px height, then add an Expanded widget - this will be the region in the Column where we'll render the list of bank accounts - we'll put it inside the Expanded widget as we want it to occupy most of the real estate of the column (we'll get back to this Expanded in a minute).
Make sure your code looks like this:
Let's get back to our Expanded widget - where we'll display the accounts.
First thing we'll do is consume the service FlutterBankService using a Consumer widget, since we want to be notified of any changes within this service so we can rebuild accordingly. Therefore, replace the existing child Container placeholder widget in the Expanded and replace it by a Consumer widget of FlutterBankService; add the corresponding builder method that injects the context, the service being listened to (FlutterBankService) and an optional child:
Back to our widget - inside of this Consumer, is where we'll consume the account data available in the FlutterBankService when we call the method getAccounts(). Since this method returns Future. we'll use a very convenient widget - a FutureBuilder, and return it out of our Consumer.
The FutureBuilder widgets build themselves based on the latest snapshot of interaction with a Future (i.e. whether we are waiting, done (succesfully or with an error), etc.).
FutureBuilder widgets take a future (in our case, the one returned from FlutterBankService's getAccounts), and a builder (that gets triggered upon the Futute changing its state), which is a callback method that gets the current BuildContext and a snapshot - a wrapper object (type AsyncSnapshot that contains the value returned by the Future object in question (in our case, a List of Account objects) as such:
Now, inside of this builder method, you can check for the connection state of the snapshot, whether the snapshot has any data, or whether it has errors, etc.
The first case we want to check is whether the snapshot.connectionState property is not done (ConnectionState.done) or if the snapshot has no data (!snapshot.hasData), so we can display some sort of spinning wheel here while the data arrives and it finishes. For now I'll just return a widget type Text with the label Loading; I'll change it to something fancier later:
Otherwise, I'll proceed further assuming I've received some data.
Pull the data from the snapshot using the .data property - which ends up being the data we pushed through the Completer's Future object in the getAccounts call when we return a Future - a List of Acount. Hold this data in a property called accounts, cast it appropriately.
Then check whether this accounts property (a List) is empty, and if so, display yet another widget that denotes that there's no data, even though the call came back successfully. For speed, I'll use a Center widget with an Icon, some spacing, and some Text lined up vertically and horizontally.
Your code should look like this:
Now, assuming every check has been taken care of, let's proceed now to pull the data being sent to us after the getAccounts call; let's display it in a list fashion (using a ListView, and each account will show on a custom AccountCard widget). For now, let's make sure we can see stuff on the screen. From the ListView.builder method, pass the itemCount (the length of accounts), an itemBuilder (a callback that executes for each entry in the collection to display); inside this callback handler, display something on a Text widget, displaying the type property out of the account in the iteration; we'll swap this for something fancier later on:
We have a problem now because this call depends on the unique authenticated UID. I'd suggest copy it for now, later we'll hook it up to the login workflow - that way we expedite the development and don't have to log in every time we make a small change to this class.
Go to the FlutterBankService service class, and inside the getAccounts() method, comment out the doc(userId) and replace the userId by the actual UID from Firebase (only for development purposes - we'll flip it back again later):
Making our first official call from Flutter into Firebase Cloud Firestore and viewing it on DartPad (drumroll please!!!):
Nice! You briefly saw the word "Loading",then we could see the value of the type field being shown on our screen - straight from Firebase Cloud Firestore!
Now we'll create a custom widget that will display the bank account information we receive from Firebase:
We'll have an AccountCard widget display per account inside our ListView widget. Here's a schematic of how we'll build it:
Let's start by creating a StatelessWidget called AccountCard; add a constructor that takes an Account object. Return an empty Container widget for the moment:
On this container, set padding of 20px all around, margin bottom only of 20px, and a fixed height of 180px. We want to limit the height of each card for consistency:
Let's add some rounded edges, set its color to white, and some shadow, via the Container's decoration property:
We've applied all styles to the Container, now let's add the child content to it:
Add a Column as the immediate Container's child. Its children will be left aligned, and spread apart evenly:
Within this column, we'll divide the content into two regions: a top and a bottom region, thus we'll be separating them into two separate columns as well.
Let's add a top Column widget with two Text widgets:
The top column should look like this:
The bottom Column widget will have its children left aligned. Add a first child widget: a Text widget with the text "Balance", with 12px font size and mainThemeColor:
Add another child widget: a Row widget with an Icon (Icons.monetization_on), with color from the mainThemeColor and 30px in size; next to it, a Text widget displaying the account balance value, using String's toStringAsFixed utility method, which formats the value as a String with 2 decimal points. Set it to 35px in font size, and append a dollar sign in front, and color it black:
Last, import the intl package at the top of the file; this package provides internationalization and localization facilities, including message translation, plurals and genders, date/number formatting and parsing, and bidirectional text. We'll use it to format a date:
import 'package:intl/intl.dart';
Below the Row widget we added earlier, add a Text widget, and use it to display today's date formatted like m/DD/YYY H:MM a (i.e 1/32/2022 4:50 PM), by calling DateFormat.yMd().add_jm().format() and passing DateTime.now() into the format() function. Apply a font size of 10px and grey in color, as such:
Let's now apply this newly created widget AccountCard and put it in place: In our FLutterBankMain widget, inside the ListView.builder method itemBuilder by removing the placeholder Text widget we have there, as such:
Run it through DartPad and how we should see the AcountCard widgets being populated and showing in the list:
We're done with showing the accounts. You may have noticed that you see a "Loading" label showing right before the accounts show - that's the Text widget we created on the condition whether the snapshot.connectionState is not done or there's no data.
We're going to create another custom widget for all our loading needs. We'll call it FlutterBankLoading so let's go aheaad and create a StatelessWidget class for it. Make it return a Center widget from its overridden build method:
As the child of the Center widget, add a SizedBox, 80x80 in dimension, and inside of it, a Stack widget (since we'll overlay items on top of each other):
First, we'll add a CircularProgressIndicator widget, which will give us the spinning progress loading effect, with a stroke width of 8px, and using the mainThemeColor, wrapped inside a fixed SizedBox (also 80x80), and in turn wrapped inside a Center widget so it is centered inside the Stack:
Last, add an Icon widget inside the Stack, so it shows on top of the circular progress indicator (Icons.savings), with size 40px and color mainThemeColor:
Now, let's go back to our FlutterBankMain widget, and replace the "Loading" Text widget by our newly created FlutterBankLoading widget, as such:
Running through DartPad, now you notice a loading animation prior to showing the acount cards, like so:
Let's wrap up this page by creating the last section of the FlutterBankMain widget: the bottom bar widget.
Let's work on the last widget of this page, which represents the bottom section that allows us to navigate to the other upcoming features of this app: the bottom bar widget:
The following is a schematic view of how we'll be building this widget:
We'll start by creating the models that will hydrate this widget. We'll start by creating a class called FlutterBankBottomBarItem, which will represent each of the items that will be shown in the FlutterBankBottomBar widget.
Add the following fields:
Your class should look like this:
In the Utils class, we'll create a utilily helper method that will generate all the items to populate our bottom bar. We'll call it getBottomBarItems, which will return a List of FlutterBankBottomBarItem objects, as such:
With that done, let's now proceed and build the FlutterBankBottomBar widget.
Create a StatelessWidget class called FlutterBankBottomBar widget, with a Container as the root of the structure:
To this Container widget, let's add a 100px height, padding of 20px all around, and via the decoration property, a color of white, and a slight shadow:
We'll now add as the child of this Container a Row widget, The Row widget will have its children laid out using its spaceAround strategy for its mainAxisAlignment:
Inside of the build method, on top of the returning Container, we'll use our utility method Utils.getBottomBarItems() to feed the list of FlutterBankBottomItem items into our row. Save the returning list on a variable called bottomBarItems:
Let's populate the children property of the Row widget now with the bottomBarItems, using the List.generate method to programmatically generate each bottom bar item from this collection. The List.generate method takes two parameters: the length of the collection containing the items to iterate on, and a callback that executes per item in the collection, which takes a parameter (index) that represent the index in the current iteration:
Now let's concentrate our efforts on this List.generate method that will crank out each bottom bar item.
Inside the callback method, pull the corresponding FlutterBankBottomItem from the current iteration using the index supplied, as such:
With this model, I'll create a Container widget, with 10px of padding all around, and a minimum with of 80px (using the constraints property set to BoxConstraints(minWidth: 80)) with a child Column with its items vertically aligned at the bottom and horizontally centered:
As the children of the Column widget, we'll add an Icon widget and a Text widget (now we'll make use of our bottomItem variable created above). Feed the bottomItem.icon to the Icon widget, set the color to mainThemeColor and an icon size of 20px; feed the bottomItem.label to the Text widget, also with the mainThemeColor and a font size of 10px:
To add tapping functionality and some of the Material effects, let's wrap our Container inside an InkWell widget. Set the following properties of the InkWell as follows:
Your code should look like this:
Let's add a Material widget wrapping the InkWell to support the notion above, with the following specs:
I believe we should be good with this bottom navigation bar. Anyway we have to get back to this widget to allow for the redirection to the other feature pages when we create them (deposit, withdrawal and expenses).
Now let's integrate this widget into our FlutterBankMain widget page.
Back on the FlutterBankMain, inside the Scaffold, set the bottomNavigationBar property to be our newly created widget FlutterBankBottomBar, as shown below:
Running it through DartPad, you should get the following output on the preview panel:
In later codelabs, we'll hook up the action to each of the bottom bar item buttons so they can navigate to their corresponding pages.
Let's implement a side menu panel that slides from the left side of the screen, triggered by a hamburger menu where we can put options like settings, signing out options, etc:
The following is a schematics view of what we'll tackle for the drawer / side menu:
Let's start as always by creating the custom widget class that extends from StatelessWidget, which we'll call FlutterBankDrawer, and return an empty Container from it:
Add a background color to this Container, as well as padding of 30px all around. As the immediate child of this Container, add a Column widget with its items aligned at the top:
Add the first widget to this Column - our app icon, using an Icon widget (Icons.savings), color white, 60px in size, as well as some spacing below it using a SizedBox, 40px in height:
Let's proceed and add a TextButton widget with a background color of white with 10% opacity (using the MaterialStateProperty.all way of assigning background colors to Material widgets); add a child Text widget with the text "Sign Out", left aligned and white in color as well. As it is required, assign an empty callback / handler to its onPressed event. We'll apply the functionality to this event shortly.
Last, wrap the whole TextButton inside a Material widget, since just like the InkWell widget, in order for them to activate their "material-ness", they need to have a Material widget as a parent. Add transparent color to the wrapping Material widget.
Your code should look like this after implementing this:
Now, in order to see it work, we now need to place this widget where it belongs - inside our FlutterBankMain's Scaffold widget as its Drawer widget. Go back to the FlutterBankMain's Scaffold, assign its drawer property by instantiating a Drawer widget, and as a child, add our newly created FlutterBankDrawer widget, as such:
Run it through DartPad to see how it's coming out, and you should see this behavior after tapping the hamburger menu that shows on the top left corner:
Let's wrap up this feature by adding the signing out functionality.
Now that we have the ability to sign into Firebase leveraging Firebase Authentication, fetching data from Firebase Cloud Firestore, let's bring it to a close by adding the signing out capabilities.
At the end, it should look like this:
Let's proceed!
Let's go to the LoginService to add the functionality.
Create a method called signOut() that returns a Future<bool> since this will also be an ansynchronous call. We'll also use a Completer here (also type bool) as we'll return a flag from this method whether the signing out was successful or not. Immediately return the Future out of the Completer for sake of speed:
Then, inside of this method, invoke the signOut method out of the FirebaseAuth.instance, we'l be hooking up two callback handlers, one for the successful result, and one to handle any error conditions. Inside the (.then()) pass the first callback used to capture the value result (if needed), and immediate complete the signOutCompleter Completer with true:
Lastly, add the error condition as a second callback attached to the .then() method call, as a callback to the onError parameter. When completing the error condition, instead of using the regular .completer out of the Completer instance, call the completeError, passing a dictionary with the error condition to be consumed on the other end.
Make sure that at the end, both conditions look like this:
Next, let's create another utility method that will generate our AlertDialog widget to display once the user taps on the "Sign Out" button, but also handles the logout if the user selects "Yes".
In the Utils class, create a static void method called signOutDialog which takes a BuildContext as a parameter:
Inside, we'll use a very convenient method provided by the framework called showDialog, which gives you the core components of a Material dialog (entrance and exit animations, modal barrier behavior), and takes (at a minimum) two arguments: a builder method for you to build your Dialog widget to whatever you want it to be, and a context (which we will be passing from the context provided) for looking up the Navigator and Theme. Feel free to explore the showDialog in detail as it offers other useful properties and customizations.
Call the showDialog method, passing the context we just injected into the Utils.signOutDialog method, and a builder callback, which takes the supplied context, as such:
For our dialog, we'll also use another useful widget provided by the framework, called AlertDialog (although we could've easily create our own), but this one already gives you the core elements of any dialog widget. Out of the builder method, return an AlertDialog widget, setting the following properties on it as detailed below:
Your AlertDialog should look like this so far:
Now, let's handle the pressing of the "Yes" TextButton, which should trigger the sign-out workflow. Inside its onPressed, the first thing we'll do is pop itself out of the Navigator navigation stack. The showDialog actually pushes a modal to the top of the stack, that's what allows it to display an overlay and the dialog and makes it show on top of the whole interface, so let's pop it out first:
Now, all we gotta do is call the signOut method from the LoginService at this point.
Retrieve an instance of the LoginService using the Provider.of, using the context passed into the builder method as such:
And now, call the signOut method out of the loginService instance. Since this returns a Future, you can either hook up a callback handler to the Future returned by calling signOut's and do a .then(), or you can decorate the onPressed callback with async, and then await on the loginService.signOut(). Either way works; I'll stick with the async/await this time. I like to show always multiple approaches on how to tackle things.
Then, after calling loginService.signOut, just pop the navigator stack once again, which results in popping the FlutterBankMain screen, taking the user back to the login screen, as it is expected.
Your code should look like this:
Now it's all a matter of going back to our FlutterBankDrawer widget, locate the "Sign Out" button and on its onPressed event handler, make sure to pop the Drawer from the navigation stack (yeah, the Drawer also gets pushed onto the stack, so popping the stack is the same as dismissing it pretty much), therefore, call the Navigator.of(context).pop() to dismiss the drawer, then display the dialog with a call to the Utils.signOutDialog and passing the current context to it.
The onPressed event on the FlutterBankDrawer's sign out method should look like this:
And with that, we've fully completed our FlutterBankMain page widget. Test the whole workflow now by undoing what we did earlier on the FlutterBankApp's MaterialApp widget, by resetting its home property to be the splash screen, the FlutterBankSplash page widget.
Thanks for making it all the way here! Hope you've learned a lot by now. Keep on going completing the rest of the codelabs!
In this codelab, we accomplished the following:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: const FirebaseOptions(
apiKey: "AIzaSyBwSM_bH2-kid-TtJPxZUo0Xw_QO8kgsU8",
authDomain: "flutter-bank-app-6ec93.firebaseapp.com",
projectId: "flutter-bank-app-6ec93",
storageBucket: "flutter-bank-app-6ec93.appspot.com",
messagingSenderId: "182673651632",
appId: "1:182673651632:web:aad3511575ff2677108875"
)
);
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(
create: (_) => LoginService(),
),
ChangeNotifierProvider(
create: (_) => FlutterBankService(),
)
],
child: FlutterBankApp()
)
);
}
class FlutterBankApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
textTheme: GoogleFonts.poppinsTextTheme(
Theme.of(context).textTheme
)
),
debugShowCheckedModeBanner: false,
home: FlutterBankSplash()
);
}
}
class FlutterBankSplash extends StatelessWidget {
@override
Widget build(BuildContext context) {
Future.delayed(const Duration(seconds: 2), () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => FlutterBankLogin())
);
});
return Scaffold(
backgroundColor: Utils.mainThemeColor,
body: Stack(
children: const [
Center(
child: Icon(Icons.savings, color: Colors.white, size: 60)
),
Center(
child: SizedBox(
width: 100,
height: 100,
child: CircularProgressIndicator(
strokeWidth: 8,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white)
)
)
)
],
)
);
}
}
class FlutterBankLogin extends StatefulWidget {
@override
FlutterBankLoginState createState() => FlutterBankLoginState();
}
class FlutterBankLoginState extends State<FlutterBankLogin>{
TextEditingController usernameController = TextEditingController();
TextEditingController passwordController = TextEditingController();
@override
Widget build(BuildContext context) {
LoginService loginService = Provider.of<LoginService>(context, listen: false);
return Scaffold(
backgroundColor: Colors.white,
body: Container(
padding: const EdgeInsets.all(30),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
border: Border.all(
width: 7,
color: Utils.mainThemeColor
),
borderRadius: BorderRadius.circular(100)
),
child: const Icon(Icons.savings, color: Utils.mainThemeColor, size: 45)
),
const SizedBox(height: 30),
const Text('Welcome to', style: TextStyle(color: Colors.grey, fontSize: 15)),
const Text('Flutter\nSavings Bank',
style: TextStyle(color: Utils.mainThemeColor, fontSize: 30)),
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Sign Into Your Bank Account',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 12)
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.2),
borderRadius: BorderRadius.circular(50)
),
child: TextField(
onChanged: (text) {
setState(() {});
},
decoration: const InputDecoration(
border: InputBorder.none,
prefixIcon: Icon(Icons.email, color: Utils.mainThemeColor),
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
contentPadding: EdgeInsets.only(
left: 20, bottom: 11, top: 11, right: 15
),
hintText: "Email"
),
style: const TextStyle(fontSize: 16),
controller: usernameController
)
),
const SizedBox(height: 20),
// password Container wrapper
Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.2),
borderRadius: BorderRadius.circular(50)
),
child: TextField(
onChanged: (text) {
setState(() {});
},
obscureText: true,
obscuringCharacter: "*",
decoration: const InputDecoration(
prefixIcon: Icon(Icons.lock, color: Utils.mainThemeColor),
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
contentPadding: EdgeInsets.only(
left: 15, bottom: 11, top: 11, right: 15
),
hintText: "Password"
),
controller: passwordController,
style: const TextStyle(fontSize: 16),
)
),
Consumer<LoginService>(
builder: (context, lService, child) {
String errorMsg = lService.getErrorMessage();
if (errorMsg.isEmpty) {
return const SizedBox(height: 40);
}
return Container(
padding: const EdgeInsets.all(10),
child: Row(
children: [
const Icon(Icons.warning, color: Colors.red),
const SizedBox(width: 10),
Expanded(
child: Text(
errorMsg,
style: const TextStyle(color: Colors.red)
)
)
]
)
);
}
)
]
)
)
),
FlutterBankMainButton(
label: 'Sign In',
enabled: validateEmailAndPassword(),
onTap: () async {
var username = usernameController.value.text;
var pwd = passwordController.value.text;
bool isLoggedIn = await loginService.signInWithEmailAndPassword(username, pwd);
if (isLoggedIn) {
usernameController.clear();
passwordController.clear();
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => FlutterBankMain())
);
}
}
),
const SizedBox(height: 10),
FlutterBankMainButton(
label: 'Register',
icon: Icons.account_circle,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => FlutterAccountRegistration())
);
},
backgroundColor: Utils.mainThemeColor.withOpacity(0.05),
iconColor: Utils.mainThemeColor,
labelColor: Utils.mainThemeColor
)
]
),
),
);
}
@override
void dispose() {
usernameController.dispose();
passwordController.dispose();
super.dispose();
}
bool validateEmailAndPassword() {
return usernameController.value.text.isNotEmpty &&
passwordController.value.text.isNotEmpty
&& Utils.validateEmail(usernameController.value.text);
}
}
class FlutterAccountRegistration extends StatefulWidget {
@override
FlutterAccountRegistrationState createState() => FlutterAccountRegistrationState();
}
class FlutterAccountRegistrationState extends State<FlutterAccountRegistration> {
TextEditingController usernameController = TextEditingController();
TextEditingController passwordController = TextEditingController();
TextEditingController confirmPasswordController = TextEditingController();
@override
void dispose() {
usernameController.dispose();
passwordController.dispose();
confirmPasswordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
LoginService loginService = Provider.of<LoginService>(context, listen: false);
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
elevation: 0,
iconTheme: const IconThemeData(color: Utils.mainThemeColor),
backgroundColor: Colors.transparent,
title: const Icon(Icons.savings, color: Utils.mainThemeColor, size: 40),
centerTitle: true
),
body: Container(
padding: const EdgeInsets.all(30),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// title
Container(
margin: const EdgeInsets.only(bottom: 40),
child: Text('Create New Account',
style: TextStyle(color: Utils.mainThemeColor, fontSize: 20)
)
),
// email field
Utils.generateInputField('Email', Icons.email,
usernameController,
false, (text) {
setState(() {});
}),
// password field
Utils.generateInputField('Password', Icons.lock,
passwordController,
true, (text) {
setState(() {});
}),
// password confirmation field
Utils.generateInputField('Confirm Password', Icons.lock,
confirmPasswordController,
true, (text) {
setState(() {});
}),
]
)
),
FlutterBankMainButton(
label: 'Register',
enabled: validateFormFields(),
onTap: () async {
String username = usernameController.value.text;
String pwd = passwordController.value.text;
bool accountCreated =
await loginService.createUserWithEmailAndPassword(username, pwd);
if (accountCreated) {
Navigator.of(context).pop();
}
}
)
]
)
)
);
}
bool validateFormFields() {
return Utils.validateEmail(usernameController.value.text) &&
usernameController.value.text.isNotEmpty &&
passwordController.value.text.isNotEmpty &&
confirmPasswordController.value.text.isNotEmpty &&
(passwordController.value.text == confirmPasswordController.value.text);
}
}
class FlutterBankMainButton extends StatelessWidget {
final Function? onTap;
final String? label;
final bool? enabled;
final IconData? icon;
final Color? backgroundColor;
final Color? iconColor;
final Color? labelColor;
const FlutterBankMainButton({
Key? key, this.label, this.onTap,
this.icon,
this.backgroundColor = Utils.mainThemeColor,
this.iconColor = Colors.white,
this.labelColor = Colors.white,
this.enabled = true })
: super(key: key);
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(50),
child: Material(
color: enabled! ? backgroundColor : backgroundColor!.withOpacity(0.5),
child: InkWell(
onTap: enabled! ? () {
onTap!();
} : null,
highlightColor: Colors.white.withOpacity(0.2),
splashColor: Colors.white.withOpacity(0.1),
child: Container(
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(50)
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Visibility(
visible: icon != null,
child: Container(
margin: const EdgeInsets.only(right: 20),
child: Icon(icon, color: iconColor, size: 20),
)
),
Text(label!, textAlign: TextAlign.center,
style: TextStyle(
color: labelColor,
fontWeight: FontWeight.bold
)
)
]
)
),
),
),
)
],
);
}
}
class FlutterBankMain extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
drawer: Drawer(child: FlutterBankDrawer()),
appBar: AppBar(
elevation: 0,
iconTheme: const IconThemeData(color: Utils.mainThemeColor),
backgroundColor: Colors.transparent,
title: const Icon(Icons.savings, color: Utils.mainThemeColor, size: 40),
centerTitle: true
),
body: Container(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Row(
children: const [
Icon(Icons.account_balance_wallet,
color: Utils.mainThemeColor, size: 30),
SizedBox(width: 10),
Text('My Accounts',
style: TextStyle(color: Utils.mainThemeColor, fontSize: 20)
)
]
),
const SizedBox(height: 20),
Expanded(
child: Consumer<FlutterBankService>(
builder: (context, bankService, child) {
return FutureBuilder(
future: bankService.getAccounts(context),
builder: (BuildContext context, AsyncSnapshot snapshot) {
if (snapshot.connectionState != ConnectionState.done || !snapshot.hasData) {
return FlutterBankLoading();
}
List<Account> accounts = snapshot.data as List<Account>;
if (accounts.isEmpty) {
return Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(Icons.account_balance_wallet, color: Utils.mainThemeColor, size: 50),
SizedBox(height: 20),
Text('You don\'t have any accounts\nassociated with your profile.',
textAlign: TextAlign.center, style: TextStyle(color: Utils.mainThemeColor))
]
)
);
}
return ListView.builder(
itemCount: accounts.length,
itemBuilder: (context, index) {
var acct = accounts[index];
return AccountCard(account: acct);
}
);
}
);
}
)
)
]
)
),
bottomNavigationBar: FlutterBankBottomBar(),
);
}
}
class AccountCard extends StatelessWidget {
final Account? account;
const AccountCard({ Key? key, this.account }) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: 180,
padding: const EdgeInsets.all(20),
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(25),
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 15,
offset: const Offset(0.0, 5.0)
)
]
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
Text('${account!.type!.toUpperCase()} ACCT', textAlign: TextAlign.left,
style: const TextStyle(color: Utils.mainThemeColor, fontSize: 12)),
Text('**** ${account!.accountNumber}')
]
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Balance', textAlign: TextAlign.left,
style: TextStyle(color: Utils.mainThemeColor, fontSize: 12)
),
Row(
children: [
const Icon(Icons.monetization_on, color: Utils.mainThemeColor, size: 30),
Text('\$${account!.balance!.toStringAsFixed(2)}',
style: const TextStyle(color: Colors.black, fontSize: 35)
)
]
),
Text('As of ${DateFormat.yMd().add_jm().format(DateTime.now())}',
style: const TextStyle(fontSize: 10, color: Colors.grey)
)
]
)
]
)
);
}
}
class FlutterBankBottomBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
var bottomItems = Utils.getBottomBarItems();
return Container(
height: 100,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Utils.mainThemeColor.withOpacity(0.05),
blurRadius: 10,
offset: Offset.zero
)
]
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: List.generate(
bottomItems.length, (index) {
FlutterBankBottomBarItem bottomItem = bottomItems[index];
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
clipBehavior: Clip.antiAlias,
child: InkWell(
highlightColor: Utils.mainThemeColor.withOpacity(0.2),
splashColor: Utils.mainThemeColor.withOpacity(0.1),
onTap: () {
bottomItem.action!();
},
child: Container(
constraints: BoxConstraints(minWidth: 80),
padding: const EdgeInsets.all(10),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(bottomItem.icon, color: Utils.mainThemeColor, size: 20),
Text(bottomItem.label!,
style: TextStyle(color: Utils.mainThemeColor, fontSize: 10)
)
]
)
)
)
);
}
)
)
);
}
}
class FlutterBankLoading extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: SizedBox(
width: 80,
height: 80,
child: Stack(
children: const [
Center(
child: SizedBox(
width: 80,
height: 80,
child: CircularProgressIndicator(
strokeWidth: 8,
valueColor: AlwaysStoppedAnimation<Color>(Utils.mainThemeColor)
)
)
),
Center(
child: Icon(Icons.savings, color: Utils.mainThemeColor, size: 40)
)
]
)
)
);
}
}
class FlutterBankDrawer extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: Utils.mainThemeColor,
padding: const EdgeInsets.all(30),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.savings, color: Colors.white, size: 60),
const SizedBox(height: 40),
Material(
color: Colors.transparent,
// rest of the code omitted for brevity...
child: TextButton(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(Colors.white.withOpacity(0.1))
),
child: const Text('Sign Out', textAlign: TextAlign.left,
style: TextStyle(color: Colors.white)
),
onPressed: () {
Navigator.of(context).pop();
Utils.signOutDialog(context);
},
)
)
]
)
);
}
}
// UTILITIES
class Utils {
static const Color mainThemeColor = Color(0xFF8700C3);
static bool validateEmail(String? value) {
String pattern =
r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]"
r"{0,253}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]"
r"{0,253}[a-zA-Z0-9])?)*$";
RegExp regex = RegExp(pattern);
return (value != null || value!.isNotEmpty || regex.hasMatch(value));
}
static Widget generateInputField(
String hintText,
IconData iconData,
TextEditingController controller,
bool isPasswordField,
Function onChanged) {
return Container(
padding: const EdgeInsets.all(5),
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.2),
borderRadius: BorderRadius.circular(50)
),
child: TextField(
onChanged: (text) {
onChanged(text);
},
obscureText: isPasswordField,
obscuringCharacter: "*",
decoration: InputDecoration(
prefixIcon: Icon(iconData, color: Utils.mainThemeColor),
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
contentPadding: EdgeInsets.only(left: 15, bottom: 11, top: 11, right: 15),
hintText: hintText
),
controller: controller,
style: const TextStyle(fontSize: 16),
)
);
}
static void signOutDialog(BuildContext context) {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text('Flutter Savings Bank Logout',
style: TextStyle(color: Utils.mainThemeColor)),
content: Container(
padding: const EdgeInsets.all(20),
child: const Text('Are you sure you want to log out of your account?')
),
actions: [
TextButton(
child: const Text('Yes', style: TextStyle(color: Utils.mainThemeColor)),
onPressed: () async {
Navigator.of(ctx).pop();
LoginService loginService = Provider.of<LoginService>(ctx, listen: false);
await loginService.signOut();
Navigator.of(ctx).pop();
},
),
],
);
},
);
}
static List<FlutterBankBottomBarItem> getBottomBarItems() {
return [
FlutterBankBottomBarItem(
label: 'Withdraw',
icon: Icons.logout,
action: () {}
),
FlutterBankBottomBarItem(
label: 'Deposit',
icon: Icons.login,
action: () {}
),
FlutterBankBottomBarItem(
label: 'Expenses',
icon: Icons.payments,
action: () {}
)
];
}
}
// SERVICES
class LoginService extends ChangeNotifier {
String _userId = '';
String _errorMessage = '';
String getErrorMessage() {
return _errorMessage;
}
void setLoginErrorMessage(String msg) {
_errorMessage = msg;
notifyListeners();
}
String getUserId() {
return _userId;
}
Future<bool> signOut() {
Completer<bool> signOutCompleter = Completer();
FirebaseAuth.instance.signOut().then(
(value) {
signOutCompleter.complete(true);
},
onError: (error) {
signOutCompleter.completeError({ 'error': error });
}
);
return signOutCompleter.future;
}
Future<bool> createUserWithEmailAndPassword(String email, String pwd) async {
try {
UserCredential userCredentials =
await FirebaseAuth.instance.createUserWithEmailAndPassword(email: email, password: pwd);
return true; // or userCredentials != null;
} on FirebaseAuthException {
return false;
}
}
Future<bool> signInWithEmailAndPassword(String email, String password) async {
setLoginErrorMessage('');
try {
UserCredential credentials = await FirebaseAuth.instance.signInWithEmailAndPassword(
email: email,
password: password,
);
_userId = credentials.user!.uid;
return true;
} on FirebaseAuthException catch (ex) {
setLoginErrorMessage('Error during sign-in: ' + ex.message!);
return false;
}
}
}
class FlutterBankService extends ChangeNotifier {
Future<List<Account>> getAccounts(BuildContext context) {
LoginService loginService = Provider.of<LoginService>(context, listen: false);
String userId = loginService.getUserId();
List<Account> accounts = [];
Completer<List<Account>> accountsCompleter = Completer();
FirebaseFirestore.instance
.collection('accounts')
.doc('h0e8z3spfrdYrQ467vIF3bf9qbo1') // use the one from YOUR project!
//.doc(userId)
.collection('user_accounts')
.get().then((QuerySnapshot collection) {
for(var doc in collection.docs) {
var acctDoc = doc.data() as Map<String, dynamic>;
var acct = Account.fromJson(acctDoc, doc.id);
accounts.add(acct);
}
Future.delayed(const Duration(seconds: 1), () {
accountsCompleter.complete(accounts);
});
});
return accountsCompleter.future;
}
}
// MODELS
class Account {
String? id;
String? type;
String? accountNumber;
double? balance;
Account({ this.id, this.type, this.accountNumber, this.balance });
factory Account.fromJson(Map<String, dynamic> json, String docId) {
return Account(
id: docId,
type: json['type'],
accountNumber: json['account_number'],
balance: json['balance']
);
}
}
class FlutterBankBottomBarItem {
String? label;
IconData? icon;
Function? action;
FlutterBankBottomBarItem({ this.label, this.icon, this.action });
}