BUILD AND TEST A CALCULATOR WITH FLUTTER
Introduction
In this tutorial, we are going to build a basic calculator that takes a string of mathematical expression, evaluates it and returns the expected result. We will be writing tests with the help of Mockito to verify if our methods are effective and reliable. Currently, dart does not support an inbuilt method that evaluates any given string of mathematical expression. for this, we will be tasked with writing our own method that will do just that for us. This method will take any string of mathematical expression and return an evaluated result but if it is not correctly formatted it will return a bad mathematical expression. below is the demo of the completed application we are going to build.
Features of this application
- Add a string of number to screen expression
- Add dot to the expression
- Add clear the screen
- Delete the last element or string in an expression
- compute mathematical string expression
We will be needing splice package that will help us to manipulate a list the way we want but don't worry we will just copy their implementation. Enough talking let us dive straight to writing our code. I am assuming that you know how to set up your project.
Declarations
// lib/evaluation_calculating.dart
class EvaluationCalculation {
var operandIndex, startIndex, endIndex, checkIndex;
var output = 0.0;
var numConcate = "";
var newArray = [];
var evaluator = [];
String calculateExpression(String expression) {}
bool checkLastIndex(String operand){}
List<T> splice<T>(List<T> list, int index,
[num howMany = 0, /*<T | List<T>>*/ elements]) {}
bool checkForNumber(String num) {}
void actualCalculation() {}
}
These variables; startIndex, endIndex, checkIndex will help us to determine the part of the screen expression that we will first evaluate if there is any parenthesis in the expression. These variables will help us to know where to traverse our list with the help of splice package implementation that we will be using. variable numConcate will separate the numbers in the strings. the new newArray will hold the list that the splice method will give us, the evaluator will hold the actual string that has been converted to list. The method calculateExpression is the core method. The splice implementation, we have copied earlier from splice package paste it under the class
Helpers Methods
//-------------------------
List<T> splice<T>(List<T> list, int index,
[num howMany = 0, /*<T | List<T>>*/ elements]) {
var endIndex = index + howMany.truncate();
list.removeRange(index, endIndex >= list.length ? list.length : endIndex);
if (elements != null)
list.insertAll(index, elements is List<T> ? elements : <T>[elements]);
return list;
}
you do not need to know the implementation.
Method to check if a given string is a number, without this we would not be able to separate the numbers in the string. this is the method below.
bool checkForNumber(String num) {
var operandOperator = [ "1", '2', '3', '4','5', '6', '7','8','9', '0', '.' ];
return operandOperator.contains(num);
}
We have written two helpers, that will help us to write our evaluation method seamlessly. Our main focus will now be turned to tackling the issue that prompted us to write those helpers in the first place.
Core Method
//inside the calculateExpression method,
String calculateExpression(String expression) {
var evaluate = expression;
//1
var splittedStrings = evaluate.split("");
if (checkLastIndex(splittedStrings[splittedStrings.length - 1])) {
splittedStrings.removeLast();
}
//2
//separate or sort out the numbers accordingly from the string
for (var i = 0; i < splittedStrings.length; i++) {
if (checkForNumber(splittedStrings[i])) {
numConcate += splittedStrings[i];
if (i == splittedStrings.length - 1) {
evaluator.add(numConcate);
numConcate = "";
}
} else {
if (numConcate != "") {
evaluator.add(numConcate);
}
evaluator.add(splittedStrings[i]);
numConcate = '';
}
}
The codes under line 1 convert or turn the string to an array and also check if the last element of the string is an operand, if true we want to remove it from expression, while everything under line 2 extract the numbers that are together as one value.
Setting the number for final evaluation
//................
for (var i = 0; i < evaluator.length; i++) {
if (i != evaluator.length - 1) {
if (checkForNumber(evaluator[i]) && evaluator[i + 1] == "(") {
splice(evaluator, i + 1, 0, "x");
}
}
if (i != evaluator.length - 1) {
if (checkForNumber(evaluator[i + 1]) && evaluator[i] == ")") {
splice(evaluator, i + 1, 0, "x");
}
}
}
The above code does not do much but it helps us to add multiplication sign between a number and bracket if the case arises, without this our method would not be able to run properly. If there is a number that directly proceeds an opening parenthesis and if there is a closing bracket that directly proceeds number.
The Evaluation Proper
//................
while (evaluator.length > 2) {
if (evaluator.contains("(")) {
startIndex = evaluator.lastIndexOf("(");
endIndex = evaluator.indexOf(")", startIndex);
// splice(evaluator, startIndex, endIndex - startIndex + 1, "checked");
newArray = evaluator.sublist(startIndex + 1, endIndex);
splice(evaluator, startIndex, endIndex - startIndex + 1, "checked");
actualCalculation();
} else {
newArray = evaluator;
actualCalculation();
}
if (evaluator.contains("checked")) {
checkIndex = evaluator.indexOf("checked");
splice(evaluator, checkIndex, 1, output.toString());
} else {
evaluator = newArray;
}
}
var answer = evaluator[0];
newArray = [];
evaluator = [];
output = 0.0;
numConcate = "";
return answer;
}
What this code does is, it checks if there is a parenthesis, if yes we will store the last index of the opening parenthesis and use it to get the corresponding closing parenthesis, then store the elements inside the parenthesis in a list. The splice method deletes the elements inside the parenthesis and parenthesis inclusive from the evaluator list and adds a string of check. Replaces the position of the string check in evaluator with the returned output from newArray. Do not worry we are going to write this missing method actualCalculation, that evaluates the newArray and returns the output. Here we go.
actualCalculation
//-------------
//BODMAS Consideration
void actualCalculation() {
while (newArray.length > 1) {
if (newArray.contains("x")) {
operandIndex = newArray.indexOf("x");
output = double.parse(newArray[operandIndex - 1]) * double.parse(newArray[operandIndex + 1]);
} else if (newArray.contains("/")) {
operandIndex = newArray.indexOf("/");
output = double.parse(newArray[operandIndex - 1]) / double.parse(newArray[operandIndex + 1]);
} else if (newArray.contains("+")) {
operandIndex = newArray.indexOf("+");
if (operandIndex != 1) {
if (newArray[operandIndex - 2] == "+") {
output = double.parse(newArray[operandIndex - 1]) + double.parse(newArray[operandIndex + 1]);
} else {
output = double.parse(newArray[operandIndex + 1]) -double.parse(newArray[operandIndex - 1]);
if (double.parse(newArray[operandIndex - 1]) >
double.parse(newArray[operandIndex + 1])) {
splice(newArray, operandIndex - 2, 1, "+");
}
}
} else {
output = double.parse(newArray[operandIndex - 1])+double.parse(newArray[operandIndex + 1]);
}
} else if (newArray.contains("-")) {
operandIndex = newArray.indexOf("-");
output = double.parse(newArray[operandIndex - 1]) -double.parse(newArray[operandIndex + 1]);
}
splice(newArray, operandIndex - 1, 3, output.toString());
}
}
//--------------
First, check if the length of the newArray is greater than one, then go ahead to evaluate the array. Adopting BODMAS rule in this evaluation. Evaluate the expression by considering the operands that are presents. Using while loop cause we do not know how many numbers of loops our method will run to evaluate our given mathematical expression. the result of each loop will be stored back into the list and at the same time removing the numbers and operand that just executed. The code will keep repeating itself till the length is no longer greater than one. Congratulations our evaluation method has been completed. now we are moving our attention to button expressions; they help us to add elements into the mathematical expression. Remaining part of this article won't take us that long, since the actual m, the mathematical expression has been tackled successfully.
//
Implementing button methods
let start with variables that are important to keep track of the position of dot.
// lib/button_expression.dart
class ButtonExpression {
bool dotTaken = false;
static final operands = ["+", "-", "/", "x"];
static List<int> operandsIndex = [];
// add to operand to expression
String addOperandToExpression(String operand, String mathsExpression){}
// add dot to the expression
String addDotToExpression(String dot, String mathsExpression){}
// numbers
String addElementToExpression(String element, String mathsExpression) {}
// delete element
String deleteElementFromExpression(String mathsExpression) {}
// check if dot exist if not add
bool checkingForDot(String mathsExpression){}
String clearExpressionFromScreen(String expression){}
}
These are all the methods we are going to write here to get all the functionalities of our app working. let us implement the methods.
Adding Operands
String addOperandToExpression(String operand, String mathsExpression) {
if (operands.contains(mathsExpression[mathsExpression.length - 1])) {
var toArray = mathsExpression.split("");
toArray[toArray.length - 1] = operand;
mathsExpression = toArray.join("");
} else {
operandsIndex.add(mathsExpression.length);
mathsExpression += operand;
}
dotTaken = false;
return mathsExpression;
}
this method checks if the last element of the mathematical expression is an operand, if yes it will replace the operand with the new, but if not, it will simply add the new operand and also setting the dotTaken variable to false.
DOT
String addDotToExpression(String dot, String mathsExpression) {
if (!dotTaken) {
mathsExpression += dot;
dotTaken = true;
}
return mathsExpression;
}
It adds dot to the expression only when dotTaken variable is false and thereby resetting it to true.
Add Element
String addElementToExpression(String element, String mathsExpression) {
mathsExpression += element;
return mathsExpression;
}
this method is simply straight forward, add new strings of numbers to the expression.
Deleting Element
String deleteElementFromExpression(String mathsExpression) {
if (mathsExpression.length > 0) {
var newMaths = mathsExpression.split("");
var elementToBeRemoved = mathsExpression[mathsExpression.length - 1];
newMaths.removeLast();
mathsExpression = newMaths.join("");
if (operands.contains(elementToBeRemoved)) {
if (checkingForDot(mathsExpression)) {
dotTaken = true;
} else {
dotTaken = false;
}
operandsIndex.removeLast();
}
}
if (mathsExpression.length == 0) {
dotTaken = false;
}
return mathsExpression;
}
It deletes the last element of mathsExpression, and also checks if the element being deleted is an operand. Then we have to check is if there is another operand in the mathsExpression if yes, check if there is a dot between the two operands, when found, we set the dotTaken to true else to false.
bool checkingForDot(String mathsExpression) {
if (operandsIndex.length > 0) {
int startIndex = operandsIndex[operandsIndex.length - 1];
int endIndex = operandsIndex.length - 1;
var newList = mathsExpression.split("").sublist(startIndex, endIndex + 1);
return newList.contains(".");
} else {
int endIndex = mathsExpression.length - 1;
if (endIndex > 0) {
var newList = mathsExpression.split("").sublist(0, endIndex + 1);
return newList.contains(".");
}
}
return false;
}
Clear
String clearExpressionFromScreen(String expression) {
dotTaken = false;
return expression = "";
}
Reset the calculator. We have so far completed the functionality of our app. After the UI section, we will check for the reliability of our code by writing test for each method.
I believe that the UI is well written out for easy comprehension.
The UI Section
import 'package:calculator/button_expression.dart';
import 'package:calculator/constants.dart';
import 'package:calculator/evaluation.dart';
import 'package:flutter/material.dart';
class HomePage extends StatefulWidget {
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
String screenExpression = '';
String evaluatedExpression = "";
EvaluationCalculation evaluation = EvaluationCalculation();
ButtonExpression btn = ButtonExpression();
Widget numberBtns(BuildContext context, String text, String expressionm) =>
GestureDetector(
onTap: () {
setState(() {
screenExpression =
btn.addElementToExpression(text, screenExpression);
});
},
child: Container(
width: 30,
child: Text(
text,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: white,
),
),
),
);
Widget operandsBtns(BuildContext context, String text, String expression) =>
GestureDetector(
onTap: () {
setState(() {
screenExpression = btn.addOperandToExpression(text, expression);
});
},
child: Container(
child: Text(text, style: textStyleOperand),
),
);
Widget dotBtn(BuildContext context, String text, String expression) =>
GestureDetector(
onTap: () {
setState(() {
screenExpression = btn.addDotToExpression(text, expression);
});
},
child: Text(text, style: textStyle),
);
Widget deleteBtn(BuildContext context, String text, String expression) =>
GestureDetector(
onTap: () {
setState(() {
evaluation.evaluator = [];
evaluation.newArray = [];
screenExpression = btn.deleteElementFromExpression(expression);
});
},
child: Text(text, style: textStyleOperand),
);
Widget evaluationBtn(BuildContext context, String text, String expression) =>
GestureDetector(
onTap: () {
setState(() {
try {
evaluatedExpression = evaluation.calculateExpression(expression);
} catch (e) {
evaluatedExpression = "BAD MATHEMATICAL EXPRESSION";
}
});
},
child: Container(
child: Text(text, style: textStyleOperand),
),
);
Widget clearExpressionBtn(
BuildContext context, String text, String expression) =>
GestureDetector(
onTap: () {
setState(() {
evaluation.evaluator = [];
evaluation.newArray = [];
screenExpression = btn.clearExpressionFromScreen(expression);
evaluatedExpression = "";
});
},
child: Container(
child: Text(text, style: textStyleOperand),
),
);
@override
return Scaffold(
backgroundColor: black,
appBar: AppBar(
title: Text("Calculator", style: TextStyle(color: Colors.white))),
body: Container(
child: Column(
children: [
Expanded(
flex: 1,
child: Container(
color: white,
width: MediaQuery.of(context).size.width,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"$screenExpression",
style: textStyleBlack,
),
Align(
alignment: Alignment.bottomRight,
child: Text(
"$evaluatedExpression",
style: textStyleEvaluatedExpression,
))
]),
)),
),
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
numberBtns(context, "7", screenExpression),
numberBtns(context, "8", screenExpression),
numberBtns(context, "9", screenExpression),
deleteBtn(context, "DEL", screenExpression)
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
numberBtns(context, "4", screenExpression),
numberBtns(context, "5", screenExpression),
numberBtns(context, "6", screenExpression),
operandsBtns(context, "/", screenExpression)
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
numberBtns(context, "1", screenExpression),
numberBtns(context, "2", screenExpression),
numberBtns(context, "3", screenExpression),
operandsBtns(context, "x", screenExpression)
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
dotBtn(context, ".", screenExpression),
numberBtns(context, "0", screenExpression),
clearExpressionBtn(context, "CLR", screenExpression),
operandsBtns(context, "-", screenExpression),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
numberBtns(context, "(", screenExpression),
numberBtns(context, ")", screenExpression),
evaluationBtn(context, "=", screenExpression),
operandsBtns(context, "+", screenExpression),
],
)
]),
),
)
],
)),
);
}
}
//app.dart
import 'package:calculator/app.dart';
import 'package:calculator/constants.dart';
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primaryColor: black,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(backgroundColor: black, body: HomePage());
}
}
TESTING ALL THE METHODS
At this point, we are done with the features we want our application to have, from here down we are going to writing test for the methods we have implemented in this application so as to ascertain if they will pass all test cases we are going to set up. In a case where the test fails, it simply means that the method has issues that need fixing. There are currently three main types of flutter tests but in this tutorial will are more concerned about the result of our methods, therefore unit testing will be enough for us. we are going to need a package called Mockito for this purpose.
Setting up dependence
//pubspec.yaml
dev_dependencies:
flutter_test:
sdk: flutter
mockito: ^4.1.1
Writing test for ButtonExpression class with its methods to ascertain the credibility of our code. You are not limited to these test cases that are provided here. I would appreciate it if you can come up with complex test cases.
// test/button_expression_test.dart
import 'package:calculator/button_expression.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
class MockButtonExpression extends Mock implements ButtonExpression {}
void main() {
MockButtonExpression mockButtonExpression;
ButtonExpression bt;
setUp(() {
mockButtonExpression = MockButtonExpression();
bt = ButtonExpression();
});
String addDotToCaseOne = "22.87+22.8";
String actualOne = "22.87+22.8";
test("Adding dot to the screen expresion case 1", () {
when(mockButtonExpression.addDotToExpression(".", addDotToCaseOne))
.thenReturn(actualOne);
// arrange
bt.dotTaken = true;
final caseOne = bt.addDotToExpression(".", addDotToCaseOne);
print(caseOne);
// assert
expect(actualOne, caseOne);
});
Test Explanation
We have to import the mock package into our test application. Declared a class that extended the mock and implements the actual class we are mirroring or testing. Declared a void main outside every class. Declared the class inside the main method as an aggregate, and initialized the class in setUp method. The when is used to return the actual value of the test case. We called the method of the class we are testing and saved the return value as our expected result. Compared the two values with the help of expect method. the test will pass if the two values match else it will fail.
// test/button_expression_test.dart
import 'package:calculator/button_expression.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
class MockButtonExpression extends Mock implements ButtonExpression {}
void main() {
MockButtonExpression mockButtonExpression;
ButtonExpression bt;
setUp(() {
mockButtonExpression = MockButtonExpression();
bt = ButtonExpression();
});
group("button_expression_testing", () {
String addDotToCaseOne = "22.87+22.8";
String addDotToCaseTwo = "2287+8";
String addDotToCaseThree = "223.9";
String expectedOne = "22.87+22.8";
String expectedTwo = "2287+8.";
String expectedThree = "223.9";
test("Adding dot to the screen expresion case 1", () {
when(mockButtonExpression.addDotToExpression(".", addDotToCaseOne))
.thenReturn(expectedOne);
// arrange
bt.dotTaken = true;
final caseOne = bt.addDotToExpression(".", addDotToCaseOne);
print(caseOne);
// assert
expect(expectedOne, caseOne);
});
// another case
test("Adding dot to the screen expresion case 3", () {
when(mockButtonExpression.addDotToExpression(".", addDotToCaseThree))
.thenReturn(expectedThree);
bt.dotTaken = true;
// arrange
final caseThree = bt.addDotToExpression(".", addDotToCaseThree);
// assert
expect(expectedThree, caseThree);
});
test("Adding dot to the screen expresion case 2", () {
when(mockButtonExpression.addDotToExpression(".", addDotToCaseTwo))
.thenReturn(expectedTwo);
// arrange
bt.dotTaken = false;
final caseTwo = bt.addDotToExpression(".", addDotToCaseTwo);
// assert
expect(expectedTwo, caseTwo);
});
});
group("button_expression_testing", () {
String deleteElement = "22.87+22.898";
String expectedResult = "22.87+22.89";
test("Delete element from screen expression", () {
when(mockButtonExpression.deleteElementFromExpression(deleteElement))
.thenReturn(expectedResult);
// arrange
final result = bt.deleteElementFromExpression(deleteElement);
// assert
expect(expectedResult, result);
});
});
group("button_expression_testing", () {
String deleteElement = "22.87+22.898";
String expectedResult = "22.87+22.89";
test("Delete element from screen expression", () {
when(mockButtonExpression.deleteElementFromExpression(deleteElement))
.thenReturn(expectedResult);
// arrange
final result = bt.deleteElementFromExpression(deleteElement);
// assert
expect(expectedResult, result);
});
});
group("button_expression_testing adding operand", () {
String case1 = "22+8+9";
String case2 = "22+8+9+";
String case3 = "22+8+9+";
String addingPlusToCase1 = "22+8+9+";
String addingMinusToCase2 = "22+8+9-";
String addingPlusToCase3 = "22+8+9+";
test("adding (+) to the screen expression 22+8+9 ", () {
when(mockButtonExpression.addOperandToExpression("+", case1))
.thenReturn(addingPlusToCase1);
// arrange
final result = bt.addOperandToExpression("+", case1);
// assert
expect(addingPlusToCase1, result);
});
test("adding (-) to the screen expression 22+8+9+ ", () {
when(mockButtonExpression.addOperandToExpression("-", case2))
.thenReturn(addingMinusToCase2);
// arrange
final result = bt.addOperandToExpression("-", case2);
print(result);
print(addingMinusToCase2);
// assert
expect(addingMinusToCase2, result);
});
test("adding (+) to the screen expression 22+8+9+ ", () {
when(mockButtonExpression.addOperandToExpression("+", case3))
.thenReturn(addingPlusToCase3);
// arrange
final result = bt.addOperandToExpression("+", case3);
// assert
expect(addingPlusToCase3, result);
});
});
test("adding numbers (9) to the screen expression", () {
String expression = "223+334";
String actualResult = "223+3349";
when(mockButtonExpression.addElementToExpression("9", actualResult))
.thenReturn(actualResult);
//arrange
final result = bt.addElementToExpression("9", expression);
// assert
expect(actualResult, result);
});
}
this is what you will get when you run the test. All the test cases provided passed our test.
// test/evaluation_calculating_test.dart
iimport 'package:calculator/evaluation_calculating.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
class MockEvaluation extends Mock implements EvaluationCalculation {}
void main() {
MockEvaluation mockEvaluation;
EvaluationCalculation evaluation;
setUp(() {
mockEvaluation = MockEvaluation();
evaluation = EvaluationCalculation();
});
group("Final Evalution of the screen expression", () {
String case1 = "((4+6)+9x8)";
String case2 = "6x5+9x4+7";
String case3 = "9((88-9+5)6+7-8)2";
String actual1 = "82.0";
String actual2 = "73.0";
String actual3 = "9054.0";
test("The final screen expression calculation ((4+6)+9x8)", () {
when(mockEvaluation.calculateExpression(case1)).thenReturn(actual1);
// arrange
final result = evaluation.calculateExpression(case1);
//assert
expect(actual1, result);
});
test("The final screen expression calculation 6x5+9x4+7", () {
when(mockEvaluation.calculateExpression(case2)).thenReturn(actual2);
// arrange
final result = evaluation.calculateExpression(case2);
//assert
expect(actual2, result);
});
test("The final screen expression calculation 9((88-9+5)6+7-8)2", () { when(mockEvaluation.calculateExpression(case3)).thenReturn(actual3);
// arrange
final result = evaluation.calculateExpression(case3);
//assert
expect(actual3, result);
});
});
}
This is what we will get when we run our test cases, I will advise that you come up with different test cases to test run your application. . Thanks for reading. Check out the complete repo on github.