Sunday, September 20, 2020

Getting Started with Dart and Flutter

 I find the Flutter framework for mobile app development to be appealing and exciting for several reasons:

  • A common source code base can be used to build both an Android and iPhone app.
  • While an app is running, you can change the source code, and the app's behavior will change while it is still running, even retaining the state of its objects.
  • The layout of widgets is straightforward, with a strong flavor of "trust the platform". It rewards figuring out the right relative arrangements of the widgets without trying to micromanage them.
  • Related to this, Flutter builds its widgets in a lightweight manner. All it uses from the underlying platform is the ability to draw pixels. This, in turn, helps avoid the Android nightmare of having to use old versions of the platform on older devices.
The Dart language that is used in the Flutter framework is a straightforward object-oriented language that should pose no difficulties for anyone with experience with languages such as Java and C#.

Hello, World!

I have been using Android Studio to build Flutter apps. When you create a new Flutter project, the "Hello World" app it generates is a simple counter, defining one function and three classes as shown below:

import 'package:flutter/material.dart';

void main() {
runApp(MyApp());
}

This program starts executing in the main() function. It calls runApp() with a newly created object of the MyApp class:

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}

The architecture of a Flutter app has a StatelessWidget at its root, to organize the basic layout. Its stateless nature is implemented by the fact that, under typical circumstances, its build() method is only called when it is created, and it really doesn't do anything other than answer calls to its build() method. 

The actual layout, then, is determined by the Widget returned by the build() method. In this program, that Widget is a MaterialApp widget, which is easily configured as shown by specifying a title, a theme, and a home. The title is simply a short string used as the name of the app. The theme describes the app's look and feel. The primary swatch is the default color used for app components. The visual density describes how densely components should be laid out. Specifying adaptive platform density basically says, "let the platform that this is running on make the decision." Finally, the home has a constructor for an object that will be the app's "home page" or starting point. In this case, it is an object of the MyHomePage class:

class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);

final String title;

@override
_MyHomePageState createState() => _MyHomePageState();
}

Just as StatelessWidget.build() is called when a StatelessWidget is created, StatefulWidget.createState() is called when a StatefulWidget is created. The resulting State object will mutate as needed in response to the pertinent system inputs. Much as with the StatelessWidget, there really isn't much to a StatefulWidgetother than the createState() method. The real action is in the State object, as we see next:

class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;

void _incrementCounter() {
setState(() {
_counter++;
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}

There are a number of important observations to make about the _MyHomePageState class:

  • Dart does not include public or private keywords; instead, any class or instance variable name that starts with an underscore ("_") is private. So the _MyHomePageState class, its _counter instance variable and its _incrementCounter() method are all private.
  • Calling State.setState() places a call to this State object's build() method on the scheduler's agenda. The build() method returns a Widget that should reflect the new state of the object. It follows that since build() is called for every state update, it is important to make sure the widget doesn't take too long to generate.
  • In this specific case, the build() method creates a Scaffold widget containing a couple of Text widgets in its body as well as a FloatingActionButton. When the latter button is pressed, the _incrementCounter() method is called, which in turn calls setState() and regenerates the widget once again. 
  • What is nice about the Scaffold widget is that it provides the app with an aesthetically appealing default appearance. The "main action" so to speak goes into the body. In this case, the body is a Center widget, which centers its contents. In turn, it contains a Column widget, instantiated with an array of widgets that are contained in the column.

Math Quizzer

Having examined this app in some detail, we will now use it as the basis for creating an app with slightly richer behavior. This app will generate randomized addition problems, the user will input answers using a text field, and the app will then display the total number of correct and incorrect answers received. 

Adding this new functionality only requires changing the _MyHomePageState class. The new version is as follows:

class _MyHomePageState extends State<MyHomePage> {
int _correct = 0;
int _incorrect = 0;
int _x = 0;
int _y = 0;
Random _rng = new Random();
TextEditingController _controller;

void initState() {
super.initState();
_controller = TextEditingController();
_reset_nums();
}

void dispose() {
_controller.dispose();
super.dispose();
}

void _reset_nums() {
_x = _rng.nextInt(12) + 1;
_y = _rng.nextInt(12) + 1;
}

void _check(String answer) {
try {
var target = int.parse(answer);
setState(() {
int total = _x + _y;
if (total == target) {
_correct += 1;
} else {
_incorrect += 1;
}
_reset_nums();
});
} on FormatException {

}
}

@override
Widget build(BuildContext context) {
TextStyle _ts = Theme.of(context).textTheme.headline4;

return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column (
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Row (
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('$_x + $_y = ', style: _ts),
SizedBox(width: 100, child: TextField(controller: _controller,style: _ts,
onSubmitted: (String value) {_check(value); _controller.clear();})),
],),
Text(
'Correct: $_correct',style: _ts,
),
Text(
'Incorrect: $_incorrect',style: _ts,
)
],
),
),
);
}
}
Instead of tracking a single counter value, the app now tracks four different values: the two addends, the number of incorrect answers, and the number of correct answers. It also has a random-number generating object as part of its state. (Be sure to import 'dart:math'; to use it.) 

A More Sophisticated Math Quizzer

This is either at or past the limit of application complexity that ought to be embedded in the user interface code. I have further refined the program to better demonstrate this separation. The full implementation is available on GitHub

Data Structures

Enumerations are relatively primitive in Dart. Unlike in Java, one cannot attach methods or data to them. I decided to handle this by using a Map (a Dart dictionary) and a custom object containing the state I desired:

enum Op {
  plus,
  minus,
  times,
  divide
}

class OpData {
  int Function(int,int) _op;
  Op _inverse;
  String _symbol;

  OpData(this._op, this._inverse, this._symbol);

  int Function(int,int) get op => _op;
  Op get inverse => _inverse;
  String get symbol => _symbol;
}

Map<Op,OpData> opData = {
  Op.plus: OpData((a, b) => a + b, Op.minus, "+"),
  Op.minus: OpData((a, b) => a - b, Op.plus, "-"),
  Op.times: OpData((a, b) => a * b, Op.divide, "x"),
  Op.divide: OpData((a, b) => a ~/ b, Op.times, "/")
};

This enum, Op, represents the four different arithmetic operators employed in a math quiz. (It is the custom in Dart to use lower-case letters for enum values.) The OpData class contains the three elements that we need to represent each Op: the calculation it makes, its inverse, and a symbol to represent it textually. The calculation is represented using a Function object. The data type of a function object is its return type, the keyword Function, and the types of its parameters. Class members whose names start with an underscore are private. The paramaters of a constructor can be designated with this.member to auto-initialize the specified member with the specified parameter. The get keyword denotes a getter method. It is invoked without parentheses or parameters to yield a non-mutable attribute.

Next we examine an ArithmeticProblem, which combines an Op with two operands to represent a problem for a student to solve:

class ArithmeticProblem {
  int _x;
  Op _op;
  int _y;
  int _result;
  int _hash;
  bool _valid = true;

  ArithmeticProblem(this._x, this._op, this._y) {
    _valid = _y != 0 || _op != Op.divide;
    if (_valid) {
      _result = opData[_op].op(_x, _y);
    }
    _hash = toString().hashCode;
  }

  ArithmeticProblem inverse() => ArithmeticProblem(_result, opData[_op].inverse, _y);

  int get answer => _result;

  bool get valid => _valid;

  bool operator ==(o) => o is ArithmeticProblem && _x == o._x && _y == o._y && _op == o._op;

  int get hashCode => _hash;

  String toString() {
    return "$_x ${opData[_op].symbol} $_y";
  }
}

The opData Map defined earlier is employed throughout the implementation of the ArithmeticProblem class. Aside from the operands and operator, each instance also includes its result, its hash code, and whether or not it is a valid problem. (A problem is invalid if there is a division by zero.) The constructor directly initializes the operands and operator, and calculates the validity, the result, and the hash code, which are then employed by the other methods as needed.

A Quiz contains a bunch of ArithmeticProblem objects as well as a random number generator. Each Quiz contains all possible problems for the given operator and the given range of operands. These problems are randomly permuted. Any incorrectly answered problems are cycled back into the problem rotation. Once all problems have been answered correctly, the Quiz is considered complete.

enum Outcome {
  correct,
  incorrect
}

class Quiz {
  List<ArithmeticProblem> _problems = List();
  List<ArithmeticProblem> _incorrect = List();
  Random _random;

  Quiz(Op op, int max, this._random) {
      for (int x = 0; x <= max; x++) {
        for (int y = 0; y <= max; y++) {
          _addValidProblem(_makeFrom(x, y, op));
        }
      }
      _problems.shuffle(_random);
  }

  void _addValidProblem(ArithmeticProblem p) {
    if (p.valid) {
      _problems.add(p);
    }
  }

  ArithmeticProblem _makeFrom(int x, int y, Op op) {
    if (op == Op.minus || op == Op.divide) {
      return ArithmeticProblem(x, opData[op].inverse, y).inverse();
    } else {
      return ArithmeticProblem(x, op, y);
    }
  }

  bool finished() => _problems.isEmpty && _incorrect.isEmpty;

  ArithmeticProblem current() {
    if (_problems.isEmpty) {
      _problems = _incorrect;
      _incorrect = List();
      _problems.shuffle(_random);
    }
    return _problems.last;
  }

  Outcome enterResponse(int response) {
    ArithmeticProblem p = _problems.removeLast();
    if (p.answer == response) {
      return Outcome.correct;
    } else {
      _incorrect.add(p);
      return Outcome.incorrect;
    }
  }

  String toString() => 'Problems:${_problems.toString()}; Incorrect:${_incorrect.toString()}';
}

The enum Outcome is introduced to enable a Quiz to report whether a given answer is correct. If it is not, the problem is added to the incorrect problems list. Once all problems have been asked, remaining incorrect problems are shuffled back into the list. Subtraction and division problems are generated by inverting addition and multiplication problems. This avoids issues of subtraction problems with negative answers and division problems with non-integer answers, both of which were beyond the scope of what was envisioned for this app.


Unit Testing

I wrote the unit tests below for these data structures. They are straightforward. The basicTest ensures that the specified operator is evaluated and inverted correctly. The testQuiz runs a scenario of quiz problems. It verifies whether the answers given in a list of answers are classified correctly as correct or incorrect. It runs the quiz through to completion.
import 'dart:math';

import 'package:flutter_test/flutter_test.dart';
import 'package:simple_math/problems.dart';

void main() {
  test('2 + 3', () {
    basicTest(Op.plus, 2, 3, 5);
  });

  test('2 * 3', () {
    basicTest(Op.times, 2, 3, 6);
  });

  test('Problems, addition', () {
    testQuiz(Op.plus, [1, 1, 2, 2, 2, 4, 4, 3, 0, 3],
        [Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.incorrect, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct]);
  });

  test('Problems, subtraction', () {
    testQuiz(Op.minus, [1, 0, 2, 0, 2, 1, 2, 2, 0, 1],
        [Outcome.correct, Outcome.correct, Outcome.incorrect, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct]);
  });

  test('Problems, multiplication', () {
    testQuiz(Op.times, [0, 0, 1, 0, 0, 2, 4, 2, 0], [Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct]);
  });

  test('Problems, division', () {
    testQuiz(Op.divide, [1, 2, 0, 1, 2, 0], [Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct, Outcome.correct]);
  });
}

void basicTest(Op op, int operand1, int operand2, int expectedValue) {
  ArithmeticProblem p1 = ArithmeticProblem(operand1, op, operand2);
  expect(p1.answer, expectedValue);
  expect(ArithmeticProblem(expectedValue, opData[op].inverse, operand2), p1.inverse());
  expect(p1.inverse().answer, operand1);
  expect(p1.inverse().inverse(), p1);
}

void testQuiz(Op op, List<int> answers, List<Outcome> expected) {
  Quiz probs = Quiz(op, 2, new Random(2));
  print("$probs");
  for (var i = 0; i < answers.length; i++) {
    expect(probs.enterResponse(answers[i]), expected[i]);
  }
  expect(probs.finished(), true);
}

Dart's unit testing infrastructure is straightforward, albeit a bit different in layout than the various xUnit frameworks. A single main() function calls all the tests. Each test is run by a call to the test function, which supplies the test code in a Function object. Within the test code, each assertion is tested using an expect function call. The expression being evaluated is the first expect parameter, and the target value is the second parameter.


User Interface Code

Flutter UI code can easily get out of hand if it is not properly decomposed into functions. I sought to do so for this app to keep the code intelligible. I will begin by describing the state data for the _MyHomePageState class.
  Widget Function() _screen;
  Quiz _problems;
  Random _rng = new Random();
  TextEditingController _controller;
  TextStyle _ts;
  Op _selected = Op.plus;
  bool _lastCorrect = true;
  int _lastMax = 12;

  void initState() {
    super.initState();
    _resetController();
    _screen = setup;
  }

  void _resetController() {
    _controller = TextEditingController(text: '$_lastMax');
  }

  void dispose() {
    _controller.dispose();
    super.dispose();
  }

The _screen object is a reference to the Function that should be employed for rendering the current app screen. I found this to be an elegant way to transition between screens - just change _screen to the appropriate function.

The _problems and _rng objects are used for the application state. The _controller and _ts objects simplify the use of certain UI components. The _selected and _lastMax objects represent the user's inputs at the start screen, and the _lastCorrect object is used to give the user feedback based on whether the last answer was correct.

The initState() method is where any object setup that is not suitable for immediate object initialization occurs. In this case, setting up the controller and setting the initial screen are both facilitated by being included here. The dispose method is unfortunately necessary for cleaning up resource usage from the _controller object.

The functions below represent the three different screens the app displays: a start screen, a problem-answering screen, and a congratulatory concluding screen.

  @override
  Widget build(BuildContext context) {
    _ts = Theme.of(context).textTheme.headline4;
    return Scaffold(
        appBar: AppBar(title: Text(widget.title)), 
        backgroundColor: pickBackground(),
        body: Center(child: _screen()));
  }
  
  Widget setup() {
    return Column (
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        radio("+", Op.plus),
        radio("-", Op.minus),
        radio("x", Op.times),
        radio("/", Op.divide),
        numericalEntry(200, "Maximum", (String value) { }),
        RaisedButton(child: Text("Start", style: _ts), onPressed: () {_start(_controller.text);}),
      ],
    );
  }

  Widget quizScreen() {
    _controller.clear();
    return Column (
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        Row (
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text("${_problems.current()} = ", style: _ts),
            numericalEntry(200, "answer", _check),
          ],),
        _restartButton()
      ],
    );
  }
  
  Widget done() {
    return Column (
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        Text("Congratulations! All answers correct.", style: _ts),
        _restartButton(),
      ],
    );
  }
    
  Color pickBackground() {
    if (_screen == quizScreen) {
      return _lastCorrect ? Colors.green : Colors.red;
    } else {
      return Colors.white;
    }
  } 

The build method shows how the _screen object is used to redirect to the current screen. (The text style is set up there due to access to the BuildContext object.) All of the screens in this app have the same basic framing, which is given in build; the role of the _screen object is to plug in a child Widget describing the elements specific to that screen. The pickBackground method determines the background color to be displayed. During a quiz, it depends on whether the last answer was correct. Otherwise, it is simply white, which given the theming would not have been otherwise necessary to specify.

The setup method lays out the startup screen. The user picks one of four radio buttons to specify the operator, specifies a maximum operand value, and when ready starts the quiz. Note that in most cases, the pertinent widgets are actually created by helper functions. This makes the logic of the screen clear and easy to follow. Given the significant boilerplate code associated with each widget, it greatly minimizes code replication as well.

The quizScreen method is the heart of the app. The column contains a row in which the user is presented the problem and given the opportunity to answer. Below that is a button for restarting the app. The string describing the current problem makes nice use of Dart's format string syntax. A $ introduces a variable; a ${} allows the insertion of a full expression.

Finally, the done method congratulates the user on completing the quiz, and uses the same _restartButton method to create a path back to the opening screen. This and the other helper methods are shown below.

  Widget numericalEntry(double width, String label, void Function(String) onSubmitted) {
    return SizedBox(width: width, child: TextField(controller: _controller, style: _ts,
      keyboardType: TextInputType.number, onSubmitted: onSubmitted,
        decoration: InputDecoration(labelText: label)));
  }

  Widget radio(String label, Op value) {
    return ListTile( title: Text(label, style: _ts),
        leading: Radio(groupValue: _selected, value: value, onChanged: (o) {setState(() {
          _selected = value;});}),
        );
  }  
  
  Widget _restartButton() {
    return RaisedButton(onPressed: _restart, child: Text("Restart", style: _ts,));
  }

The numericalEntry method creates an area that allows text entry of numerical values. The Flutter TextField is somewhat finicky; it needs to be deployed within a SizedBox (or another size-controlling widget). The InputDecoration provides a handy way to provide a hint to the user as to what is expected in the box without having to prefix it with a label. The onSubmitted object represents the event handler.

Radio buttons are very straightforward as well. The groupValue designates a variable that tracks which radio button the user has selected. It is updated in the event handler, which in this case is in-place. (The other event handlers in this app are separate functions.)

The raisedButton object is used for the restart button. Note that placing text on a button requires giving it a child widget.

Finally we have the event-handling methods:

  void _processNumberEntry(String value, void Function(int) processor) {
    try {
      processor(int.parse(value));
    } on FormatException {
      print("Threw an exception...");
    }
  } 

  void _start(String value) {
    _processNumberEntry(value, (v) { 
      setState(() {
        _lastMax = v;
        _lastCorrect = true;
        _problems = Quiz(_selected, _lastMax, _rng);
        _screen = quizScreen;
      });
    });
  }

  void _check(String answer) {
    _processNumberEntry(answer, (target) {
      setState(() {
        Outcome result = _problems.enterResponse(target);
        _lastCorrect = (result == Outcome.correct);
        if (_problems.finished()) {
          _screen = done;
        }
      });
    });
  }
    
  void _restart() {
    setState(() {
      _resetController();
      _screen = setup;
    });
  }

The _start method starts a quiz, and the _check method processes an answer. I didn't write a substantive event handler, as using the numeric entry screen should suffice to ensure that FormatException is not possible to raise. By placing the exception handling code in _processNumberEntry, if at a later time I wish to do something more substantive, I won't have to replicate it in both _start and _check. The _restart method returns to the start screen. The resetController call makes sure that the previous maximum operand value reappears on the start screen.

In all three event handlers, an update to the user interface is triggered by a call to setState. The code passed to setState is executed later by the user interface framework prior to refreshing the screen. Transitions between screens are controlled by setting the _screen object to the desired new function. In this way, _screen represents a state in a state machine that transitions based on the events that occur and the app's state.

Happy app building!

No comments:

Post a Comment