You will also learn about the following:
This is what we'll be accomplishing in this codelab: users will be able to pull account balance information and decrement the balance by performing a withdrawal. Here's the widget page we'll be working on:
Here's the schematics on what we'll be building regarding the withdrawal page:
Let's start from the services side of things. We will create a new service that will hold on to the information on the amount be deposited.
Let's call this new service WithdrawalService, and will hold on to on the amount value to be withdrawn, plus other functionality.
Create a class for the WithdrawalService service with an internal property called amountToWithdraw, type double. Make this service extend ChangeNotifier as this service will take care of notifying listeners accordingly:
Let's add three methods that will perform the following:
Your code inside the WithdrawalService should look like this:
Don't forget to inject the newly created WithdrawalService at the root of our application, inside the list of providers of our MultiProvider widget, as such:
Now, let's get back to the FlutterBankService and add some needed functionality to support this feature.
Now, let's add a new method called performWithdrawal, which returns a Future<bool> and takes a BuildContext reference. We'll also be using a Completer object to generate a future through which we'll complete this operation. Go ahead and add a Completer of type bool called depositComplete and return a Future out of it at the end of this method, as such:
The reason why we're passing the BuildContext context as a parameter to this method is because we'll also be fetching the data available on other provided services, and the only way to pull them is through the Provider.of mechanism, which requires a context passed to it.
We'll need to fetch both the LoginService to get the unique UID from the user, as well as the WithdrawalService created earlier to get the amountToWithdraw value, so let's do just that; we'll grab the required values and store them in local variables:
Now, after fetching these values, we'll perform the storing of the data to Firebase. We have the unique user id, which we'll use to fetch the corresponding document associated with the user, as well as the amount we want to change the specific account (the one that will be selected by the user and stored on selectedAccount), therefore we'll grab the document associated with that selecteAccount's id.
Let's inspect the query:
We are starting at the root collection (accounts), then searching for the document in the root collection by user id, then search inside that document found inside its nested collection (user_accounts), and in turn, find a document associated with the selected account's id. At the end, the returning document is stored in a variable called doc, type DocumentReference.
Then, on this document reference, we can perform the update to its balance field, where we will grab the existing balance and deduct the value in amountToWithdraw that we fetched from the WithdrawalService. The update method takes a dictionary object with the name of the field to update as key (‘balance') and the value the actual value to be assigned / updated to:
We could leave it up to here and it should work, but we kind of want to be notified when the update was performed and if it was done successfully or with an error, and of course we want to notify listeners that the update happed, so let's instead hook up to the .then method since the update call on the DocumentReference itself returns a Future, so we can chain success and error events, as such:
Notice other things we did on the event chaining we did on the .then(): upon a successful update, we invoke the wService.resetWithdrawalService, that way we can reset the amountToWithdraw property back to zero, ready to take on a new transaction, and then completing the Future (withdrawComplete.complete(true)) so whoever is listening can be notified that the Future completed.
On the onError handler, we are completing the Future, but with an error (withdrawComplete.completeError) and passing the error received in a dictionary with key error.
With this, we've completed all updates we needed to do as far as services is concerned. Now, on to implementing the UI that will consume these services!
Let's start by creating the class that represents the page widget that will perform the deposits. Let's call it FlutterBankWithdrawal that extends StatelessWidget. Return a Scaffold with white as its backgroundColor:
Add an AppBar like in the other pages with the same properties:
As the body of the Scaffold, set it as a Container, with 20px padding all around, and as its direct child, add a Column with its items left aligned, as such:
Great! We have the foundation for our page.
Yup, we already built this widget in the previous lab, so all we have to do is add it to the list of widgets of this column, and passing the appropriate parameters (see the benefits of making reusable / shareable widgets?).
As the first child of this Column, add the previously created AcccountActionHeader, passing to it the headerTitle and icon, as follows:
Run this on DartPad and see how's starting to look:
Great! Let's move on.
We've also created this widget already in the previous lab as well as the structure that displays the balance (wow, so cool!) so the only remaining thing we will have to create is create the wrapper Expanded widget that will make this widget occupy most of the space in the withdrawal page's column. Pass the two parameters required by the AccountActionSelection (the actionTypeLabel "From" and the amountChanger for now pass an empty Container since this parameter is required; this is just a placeholder for the withdrawal slider widget we'll be building in the next section).
The AccountActionSelection code in the FlutterBankWithdrawal widget page should look like this:
And then running this on DartPad:
Such a breeze! Moving on...
Let's now create a widget that allows the user to provide an amount to be deduced out of our account's balance from Firestore as a withdrawal. The widget will be pretty much exactly the same as our AccountDepositSlider but only tapping into the WithdrawalService, so we will just create it based on that.
Same thing as the deposit slider, When the user moves the slider, it changes the value in the label above, which will trigger a notification on every value change. Other widgets will be listening to whether there has been a valid value provided by this widget, thus enabling or disabling themselves accordingly. We'll see that later.
Let's proceed.
We'll create a class called AccountWithdrawalSlivder that extends StatelessWidget; please copy / paste the same exact build method of the AccountDepositSlider and paste it as the build method of this widget.
In the root Consumer widget, replace the DepositService by the corresponding WithdrawalService; rename it on the builder method and call it withdrawalService:
Replace every instance of deposit.amountToDeposit by withdrawalService.amountToWithdraw, as well as every instance of deposit.setAmountToWithdraw to withdrawalService.setAmountToWithdraw; also replace the Text widget content to "Amount to Withdraw":
Back on our FlutterBankWithdrawal widget, inside of the AccountActionSelection widget, replace the placeholder Container by our newly created AccountWithdrawalSlider widget, as such:
Run it through DartPad to see our progress:
Let's make one tiny change in which we need to limit the amount we need to withdraw: it cannot be a hard-coded value of 1000 like we had it for the AmountDepositSlider; we need to limit the amount to be no more than the amount currently available at the moment. Therefore, Inside the AccountWithdrawalSlider, wrap the current Consumer of the WithdrawalService within a Consumer, but of the FlutterBankService, that way, upon the bank selection changing and thus triggering a change, this widget will rebuild itself accordingly as well, and we can pull the balance information from the outer wrapping Consumer with the FlutterBankService there, as such:
Let's also add some additional logic to display the correct amount value to withdraw upon the user switching accounts, and the current amount to withdraw is larger than the one in the previous account, otherwise just show the existing balance as we don't want to show the previous account which might be larger than the other one:
Then, use the actualAmount value instead of the previous one (withdrawService.amountToWithdraw) as we were using it straight up. Replace the Slider's max by the currently selected account's balance (bankService.selectedAccount!.balance!), as such:
And now you're all set! Now we are able to switch accounts without thinking about whether one account's balance is larger than the other and the UI misbehaving due to the discrepacies in amounts - all thanks to a well-architected widget structure we get our updates as expected, and widgets trigger in response to changes accordingly.
Now let's move down to the last piece of this page.
Now let's bring the last widget of this page, which will be the "Make Withdrawal" button. This button will be what ultimately triggers the withdrawal workflow and performs the update to the balance on the selected account to Firebase Cloud Firestore.
As the last item in the FlutterBankWithdrawal main Column structure, below the AccountActionSelection wrapper Expanded widget, add a Consumer that also listens to the WithdrawalService; we'll do this so that when the AccountWithdrawalSlider widget changes its value to a valid value to be withdrawn, our Consumer will rebuild and evaluate a condition whether to enable / disable the button we will display to perform the saving.
So, add the Consumer<WithdrawalService> at the end of the Column:
Out of the Consumer's builder method, return a FlutterBankMainButton with the following specs:
Confirm your code looks like this after implementing it:
Let's run this in DartPad and take it for a spin; move the slider and change its value; make sure sometimes you bring the value to zero to notice how the button disables itself (since the condition is hit) and so on.
You should see the FlutterBankMainButton behaving like below:
Both deposit or withdrawal, as we mentioned in the previous lab, will be delegated to a separate page, which is called TransactionCompletionPage (already created); this page performs either one of the calls, returning the result and display it to the user, as well as redirect them to the main page for further transactions.
We'll only do one refactoring here, in which the current TransactionCompletionPage widget is only handling the deposit call to Firebase and ignoring the isDeposit flag.
Let's take care of that.
Go to the TransactionCompletionPage widget, and inside the FutureBuilder, assign its future property the result of checking whether the isDeposit flag is true or false, and assign the corresponding call (either performDeposit or performWithdrawal) from the FlutterBankService as follows:
Now that we've refactored this page and made it so it could work for either workflow, let's hook it up to the "Make Withdrawal" button on our FlutterBankWithdrawal page from earlier.
Go back to the FlutterBankWithdrawal, locate the Consumer widget wrapping the FlutterBankMainButton for the deposit, and on the onTap event, let's do the following logic: let's check for whether the amount to make a withdrawal is valid (greater than zero), if so, then we'll hook up an event to it that navigates the user to our TransactionCompletionPage (using the Navigator.of(context) and calling pushReplacement).
Then, to the TransactionCompletionPage widget, pass as a parameter the flag isDeposit to false (since true means it is a deposit), otherwise, just assign null to the onTap event handler (setting a button's event handler to null just makes it disabled to clicking).
Your code should look like this:
If you save and try it in DartPad, you will see the value being saved, and the transaction page showing it completed successfully.
Let's bring this whole workflow from end to end.
Now we need to find the way to trigger this page we just created, and we have just the place: the bottom navigation bar.
Remember we had there some unfinished event handlers attached to each of the actions in the bottom bar. Well, let's populate the one for the withdrawal since we did the one for the deposit earlier.
Go to the Utils class, and in the getBottomBarItems locate the FlutterBankBottomBarItem with label "Withdraw", and on its action handler method, perform a navigation to the FlutterBankWithdrawal, using the provided context to this method. Your code should look like this:
We'll do the same thing we did in the FlutterBankDeposit as far as clearing the selection of the bank account, which was using the WillPopScope widget.
As we mentioned, the way to accomplish that is through a widget called WillPopScope. With this widget you can pretty much intercept the dismissal of the current page in order to perform one last action (like a clean-up action, in our case, resetting the selected account).
Let's work on this.
Let's go to the FlutterBankWithdrawal widget page and wrap the whole root widget in your build method (in our case, the Scaffold) inside the WillPopScope widget. Add the required argument onWillPop which is a callback where you can handle your cleanup before the page is dismissed, as such, and as before, inside the onWillPop, use the Provider.of to pull a reference of the FlutterBankService here. At the end of the call, return a Future.value(true), required by this method, because if the callback returns a Future that resolves to false, the enclosing route will not be popped.
Check your code against the one below:
If you try it now, you will see that getting back to the main and then back to withdraw, it won't persist the selection, since we are resetting it before leaving the page.
And with that, we've completed the withdraw page for this application! I hope you've learned some additional tricks and best practices that you can go ahead and implement on your own Flutter apps. Cheers!
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(),
),
ChangeNotifierProvider(
create: (_) => DepositService(),
),
ChangeNotifierProvider(
create: (_) => WithdrawalService(),
),
],
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: const 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 FlutterBankDeposit extends StatelessWidget {
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () {
FlutterBankService bankService =
Provider.of<FlutterBankService>(context, listen: false);
bankService.resetSelections();
return Future.value(true);
},
child: 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(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const AccountActionHeader(headerTitle: 'Deposit', icon: Icons.login),
Expanded(
child: AccountActionSelection(
actionTypeLabel: 'To',
amountChanger: AccountDepositSlider(),
),
),
Consumer<DepositService>(
builder: (context, depositService, child) {
return FlutterBankMainButton(
enabled: depositService.checkAmountToDeposit(),
label: 'Make Deposit',
onTap: (depositService.checkAmountToDeposit() ? () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) =>
const TransactionCompletionPage(isDeposit: true)
)
);
} : null)
);
}
)
]
)
)
),
);
}
}
class FlutterBankWithdrawal extends StatelessWidget {
@override
Widget build(BuildContext context) {
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(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const AccountActionHeader(headerTitle: 'Withdraw', icon: Icons.logout),
Expanded(
child: AccountActionSelection(
actionTypeLabel: 'From',
amountChanger: AccountWithdrawalSlider(),
),
),
Consumer<WithdrawalService>(
builder: (context, withdrawalService, child) {
return FlutterBankMainButton(
enabled: withdrawalService.checkAmountToWithdraw(),
label: 'Make Withdrawal',
onTap: withdrawalService.checkAmountToWithdraw() ? (){
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) =>
const TransactionCompletionPage(isDeposit: false))
);
} : null
);
}
)
]
)
)
);
}
}
class TransactionCompletionPage extends StatelessWidget {
final bool? isDeposit;
const TransactionCompletionPage({Key? key, this.isDeposit }):
super(key: key);
@override
Widget build(BuildContext context) {
FlutterBankService bankService =
Provider.of<FlutterBankService>(context, listen: false);
Future.delayed(const Duration(seconds: 3), () {
bankService.resetSelections();
Navigator.of(context).pop();
});
return WillPopScope(
onWillPop: () {
bankService.resetSelections();
return Future.value(true);
},
child: 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
),
// rest of the code omitted for brevity...
body: Center(
child: FutureBuilder(
future: isDeposit! ?
bankService.performDeposit(context) :
bankService.performWithdrawal(context),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return FlutterBankLoading();
}
if (snapshot.hasError) {
return FlutterBankError();
}
return FlutterBankTransactionCompleted();
}
)
)
)
);
}
}
class FlutterBankTransactionCompleted extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: const [
Icon(Icons.check_circle_outline_outlined,
color: Utils.mainThemeColor, size: 80
),
SizedBox(height: 20),
Text('Transaction Completed',
style: TextStyle(color: Utils.mainThemeColor, fontSize: 20)
),
]
);
}
}
class AccountActionSelection extends StatelessWidget {
final String? actionTypeLabel;
final Widget? amountChanger;
const AccountActionSelection({
this.actionTypeLabel,
required this.amountChanger
});
@override
Widget build(BuildContext context) {
return Consumer<FlutterBankService>(
builder: (context, service, child) {
return FutureBuilder(
future: service.getAccounts(context),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return FlutterBankLoading();
}
if (snapshot.hasError) {
return FlutterBankError();
}
var selectedAccount = service.getSelectedAccount();
List<Account> accounts = snapshot.data as List<Account>;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(actionTypeLabel!,
style: const TextStyle(color: Colors.grey, fontSize: 15)
),
const SizedBox(height: 10),
AccountActionCard(
selectedAccount: selectedAccount,
accounts: accounts,
),
Expanded(
child: Visibility(
visible: selectedAccount != null,
child:
Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
margin: const EdgeInsets.only(top: 30),
child: const Text('Current Balance',
style: TextStyle(color: Colors.grey)
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.monetization_on,
color: Utils.mainThemeColor, size: 25
),
Text(selectedAccount != null ?
'\$' + selectedAccount.balance!.toStringAsFixed(2): '',
style: const TextStyle(color: Colors.black, fontSize: 35)
)
]
),
Expanded(
child: amountChanger!,
)
]
),
)
)
]
);
}
);
}
);
}
}
class AccountDepositSlider extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<DepositService>(
builder: (context, depositService, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Amount To Deposit', style:
TextStyle(color: Colors.grey)),
Text('\$${depositService.amountToDeposit.toInt().toString()}',
style: const TextStyle(color: Colors.black, fontSize: 60)
),
Slider(
value: depositService.amountToDeposit,
max: 1000,
activeColor: Utils.mainThemeColor,
inactiveColor: Colors.grey.withOpacity(0.5),
thumbColor: Utils.mainThemeColor,
onChanged: (double value) {
depositService.setAmountToDeposit(value);
}
)
]
);
}
);
}
}
class AccountWithdrawalSlider extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<FlutterBankService>(
builder: (context, bankService, child) {
return Consumer<WithdrawalService>(
builder: (context, withdrawalService, child) {
double amountToWithdraw = withdrawalService.amountToWithdraw;
double currentBalance = bankService.selectedAccount!.balance!;
double actualAmount = amountToWithdraw > currentBalance ?
currentBalance : amountToWithdraw;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Amount To Withdraw', style:
TextStyle(color: Colors.grey)),
Text('\$${actualAmount.toInt().toString()}',
style: const TextStyle(color: Colors.black, fontSize: 60)
),
Slider(
value: actualAmount,
max: bankService.selectedAccount!.balance!,
activeColor: Utils.mainThemeColor,
inactiveColor: Colors.grey.withOpacity(0.5),
thumbColor: Utils.mainThemeColor,
onChanged: (double value) {
withdrawalService.setAmountToWithdraw(value);
}
)
]
);
}
);
},
);
}
}
class AccountActionCard extends StatelessWidget {
final List<Account>? accounts;
final Account? selectedAccount;
const AccountActionCard({ this.accounts, this.selectedAccount });
@override
Widget build(BuildContext context) {
FlutterBankService bankService =
Provider.of<FlutterBankService>(context, listen: false);
return Row(
children: List.generate(accounts!.length, (index) {
var currentAccount = accounts![index];
return Expanded(
child: GestureDetector(
onTap: () {
bankService.setSelectedAccount(currentAccount);
},
child: Container(
margin: const EdgeInsets.all(5),
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 20, offset: const Offset(0.0, 5.0)
)
],
border: Border.all(
width: 5,
color: selectedAccount != null &&
selectedAccount!.id == currentAccount.id ?
Utils.mainThemeColor : Colors.transparent
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${currentAccount.type!.toUpperCase()} ACCT',
style: const TextStyle(color: Utils.mainThemeColor)
),
Text(currentAccount.accountNumber!)
]
)
),
),
);
})
);
}
}
class FlutterBankError extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: const [
Icon(Icons.warning_outlined, color: Utils.mainThemeColor, size: 80),
SizedBox(height: 20),
Text('Error fetching data',
style: TextStyle(color: Utils.mainThemeColor, fontSize: 20)
),
Text('Please try again',
style: TextStyle(color: Colors.grey, fontSize: 12)
)
]
)
);
}
}
class AccountActionHeader extends StatelessWidget {
final String? headerTitle;
final IconData? icon;
const AccountActionHeader({ this.headerTitle, this.icon });
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 20),
child: Row(
children: [
Icon(icon, color: Utils.mainThemeColor, size: 30),
const SizedBox(width: 10),
Text(headerTitle!,
style: const TextStyle(color: Utils.mainThemeColor, fontSize: 20)
)
]
),
);
}
}
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(context);
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: const 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: const 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: const 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(BuildContext context) {
return [
FlutterBankBottomBarItem(
label: 'Withdraw',
icon: Icons.logout,
action: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => FlutterBankWithdrawal())
);
}
),
FlutterBankBottomBarItem(
label: 'Deposit',
icon: Icons.login,
action: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => FlutterBankDeposit())
);
}
),
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 {
await FirebaseAuth.instance.createUserWithEmailAndPassword(email: email, password: pwd);
return true;
} 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 {
Account? selectedAccount;
void setSelectedAccount(Account? acct) {
selectedAccount = acct;
notifyListeners();
}
void resetSelections() {
setSelectedAccount(null);
}
Account? getSelectedAccount() {
return selectedAccount;
}
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(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);
});
},
onError: (error) {
accountsCompleter.completeError({ 'error': error });
}
);
return accountsCompleter.future;
}
Future<bool> performDeposit(BuildContext context) {
Completer<bool> depositComplete = Completer();
LoginService loginService = Provider.of<LoginService>(context, listen: false);
String userId = loginService.getUserId();
DepositService depositService = Provider.of<DepositService>(context, listen: false);
int amountToDeposit = depositService.amountToDeposit.toInt();
DocumentReference doc =
FirebaseFirestore.instance
.collection('accounts')
.doc(userId)
.collection('user_accounts')
.doc(selectedAccount!.id!);
doc.update({
'balance': selectedAccount!.balance! + amountToDeposit
}).then((value) {
depositService.resetDepositService();
depositComplete.complete(true);
}, onError: (error) {
depositComplete.completeError({ 'error': error });
});
return depositComplete.future;
}
Future<bool> performWithdrawal(BuildContext context) {
Completer<bool> withdrawComplete = Completer();
LoginService loginService = Provider.of<LoginService>(context, listen: false);
String userId = loginService.getUserId();
WithdrawalService wService = Provider.of<WithdrawalService>(context, listen: false);
int amountToWithdraw = wService.amountToWithdraw.toInt();
DocumentReference doc =
FirebaseFirestore.instance
.collection('accounts')
.doc(userId)
.collection('user_accounts')
.doc(selectedAccount!.id!);
doc.update({
'balance': selectedAccount!.balance! - amountToWithdraw
}).then((value) {
wService.resetWithdrawalService();
withdrawComplete.complete(true);
}, onError: (error) {
withdrawComplete.completeError({ 'error': error });
});
return withdrawComplete.future;
}
}
class DepositService extends ChangeNotifier {
double amountToDeposit = 0;
void setAmountToDeposit(double amount) {
amountToDeposit = amount;
notifyListeners();
}
void resetDepositService() {
amountToDeposit = 0;
notifyListeners();
}
bool checkAmountToDeposit() {
return amountToDeposit > 0;
}
}
class WithdrawalService extends ChangeNotifier {
double amountToWithdraw = 0;
void setAmountToWithdraw(double amount) {
amountToWithdraw = amount;
notifyListeners();
}
void resetWithdrawalService() {
amountToWithdraw = 0;
notifyListeners();
}
bool checkAmountToWithdraw() {
return amountToWithdraw > 0;
}
}
// 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 });
}