Creating full-screen dialog in Flutter – WeightTracker 2

Creating full-screen dialog in Flutter – WeightTracker 2

In this post I would like to go through the process of creating full-screen dialog. Dialogs like that are used for more complex user operations that would be inappropriate for normal dialog. You can read more about Material Design’s specification here. Luckily, Flutter framework provides very simple way to create and use full-screen dialogs. You can find one of them in Flutter’s Gallery app.

The presented dialog is part of my Weight Tracker app (simple app for tracking weight), so the development will be based on that and focused for that purpose.

1. Creating basic UI

Full-screen dialog as a Widget doesn’t really differ from a normal screen. How we are going to create it is by populating basic Scaffold:

import 'package:flutter/material.dart';

class AddEntryDialog extends StatefulWidget {
  @override
  AddEntryDialogState createState() => new AddEntryDialogState();
}

class AddEntryDialogState extends State<AddEntryDialog> {
  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: const Text('New entry'),
        actions: [
          new FlatButton(
              onPressed: () {
                //TODO: Handle save
              },
              child: new Text('SAVE',
                  style: Theme
                      .of(context)
                      .textTheme
                      .subhead
                      .copyWith(color: Colors.white))),
        ],
      ),
      body: new Text("Foo"),
    );
  }
}

In the actions property we added a FlatButton which will handle save action.

We also added new Style to the button’s Text, so it matches the header.

2. Opening a dialog

To open a dialog we will use Navigator class, which is Flutter’s tool for navigating screens and layout flows. We can add new views to the stack by calling Navigator’s push method and close them by calling pop.

void _openAddEntryDialog() {
  Navigator.of(context).push(new MaterialPageRoute<Null>(
      builder: (BuildContext context) {
        return new AddEntryDialog();
      },
    fullscreenDialog: true
  ));
}

The one thing worth mentioning is fullscreenDialog flag. Setting it up will provide out screen “close symbol” in top left corner instead of a default “back arrow”. On the iOS devices it also affects swipe back behavior.

If you are interested in Flutter’s navigation management and what is MaterialPageRoute I used in that example, I highly recommend reading official documentation on that class. It is written very clearly and provides all the information needed for understanding Flutter’s navigation.

This is what we have so far:

3. Receiving a result

Full-screen dialogs are usually used for add/edit operations. Having this in mind we would like to have our dialog returning some value when user presses SAVE. To do that we need to use more Navigation and Routing utilities.

In previous example we provided a MaterialPageRoute<Null> object. Flutter’s Routes are designed to return a value after they are popped from the stack. We achieve this by specifying type of the object we want to receive and adding a bit of asynchronous features to our app.

Future _openAddEntryDialog() async {
  WeightSave save = await Navigator.of(context).push(new MaterialPageRoute<WeightSave>(
      builder: (BuildContext context) {
        return new AddEntryDialog();
      },
    fullscreenDialog: true
  ));
  if (save != null) {
    _addWeightSave(save);
  }
}

Navigator’s push method returns a Feature object which is something like a promise of an object to come. In our case, we want the dialog to return a WeightSave item. In order to achieve this we add an await keyword which causes code execution to stop and wait for a response from asynchronous push method. The value is returned when the pop method is called. After that will happen we check if we received any value and then add it to list.

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:weight_tracker/model/WeightSave.dart';

class AddEntryDialog extends StatefulWidget {
  @override
  AddEntryDialogState createState() => new AddEntryDialogState();
}

class AddEntryDialogState extends State<AddEntryDialog> {
  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: const Text('New entry'),
        actions: [
          new FlatButton(
              onPressed: () {
                Navigator
                    .of(context)
                    .pop(new WeightSave(new DateTime.now(), new Random().nextInt(100).toDouble()));
              },
              child: new Text('SAVE',
                  style: Theme
                      .of(context)
                      .textTheme
                      .subhead
                      .copyWith(color: Colors.white))),
        ],
      ),
      body: new Text("Foo"),
    );
  }
}

In the Dialog Class we simply call Navigator.pop and provide value to return. If we don’t specify the returned value or we close our screen in other way (e.g. by pressing back) the default value will be null.

Right now we can test that our our dialog is returning a value.

4. Complex FullScreen Dialog layout

Now it’s time to get rid of that Foo text!

Our main requirements for a dialog are to allow it to pick a date and weight. I also added a possibility to add a note. If you are interested in all components combined, there is a GitHub link at the end of this post.

4.1 Picking date and time

new ListTile(
  leading: new Icon(Icons.today, color: Colors.grey[500]),
  title: new DateTimeItem(
    dateTime: _dateTime,
    onChanged: (dateTime) => setState(() => _dateTime = dateTime),
  ),
),
class DateTimeItem extends StatelessWidget {
  DateTimeItem({Key key, DateTime dateTime, @required this.onChanged})
      : assert(onChanged != null),
        date = dateTime == null
            ? new DateTime.now()
            : new DateTime(dateTime.year, dateTime.month, dateTime.day),
        time = dateTime == null
            ? new DateTime.now()
            : new TimeOfDay(hour: dateTime.hour, minute: dateTime.minute),
        super(key: key);

  final DateTime date;
  final TimeOfDay time;
  final ValueChanged<DateTime> onChanged;

  @override
  Widget build(BuildContext context) {
    return new Row(
      children: <Widget>[
        new Expanded(
          child: new InkWell(
            onTap: (() => _showDatePicker(context)),
            child: new Padding(
                padding: new EdgeInsets.symmetric(vertical: 8.0),
                child: new Text(new DateFormat('EEEE, MMMM d').format(date))),
          ),
        ),
        new InkWell(
          onTap: (() => _showTimePicker(context)),
          child: new Padding(
              padding: new EdgeInsets.symmetric(vertical: 8.0),
              child: new Text('$time')),
        ),
      ],
    );
  }

  Future _showDatePicker(BuildContext context) async {
    DateTime dateTimePicked = await showDatePicker(
        context: context,
        initialDate: date,
        firstDate: date.subtract(const Duration(days: 20000)),
        lastDate: new DateTime.now());

    if (dateTimePicked != null) {
      onChanged(new DateTime(dateTimePicked.year, dateTimePicked.month,
          dateTimePicked.day, time.hour, time.minute));
    }
  }

  Future _showTimePicker(BuildContext context) async {
    TimeOfDay timeOfDay =
        await showTimePicker(context: context, initialTime: time);

    if (timeOfDay != null) {
      onChanged(new DateTime(
          date.year, date.month, date.day, timeOfDay.hour, timeOfDay.minute));
    }
  }
}

DateTimeItem is a class that I borrowed from Flutter’s Gallery app with some minor changes. It contains a row with two Texts. First of them is representing date and after it is clicked the DatePicker pops up allowing user to pick a date. Similarly second text shows time picker and allows user to pick a TimeOfDay.

After she or he does it, we simply create new DateTime with usage of new values and dates provided in constructor and pass it by our ValueChanged’s onChange method. After that parent class calls setState with new DateTime object and UI refreshes with new date user has picked.

4.2 Picking weight

new ListTile(
  leading: new Image.asset(
    "assets/scale-bathroom.png",
    color: Colors.grey[500],
    height: 24.0,
    width: 24.0,
  ),
  title: new Text(
    "$_weight kg",
  ),
  onTap: () => _showWeightPicker(context),
),

_showWeightPicker(BuildContext context) {
  showDialog(
    context: context,
    child: new NumberPickerDialog.decimal(
      minValue: 1,
      maxValue: 150,
      initialDoubleValue: _weight,
      title: new Text("Enter your weight"),
    ),
  ).then((value) {
    if (value != null) {
      setState(() => _weight = value);
    }
  });
}

To pick a weight value I decided to use simple ListTile, which on tap would open NumberPickerDialog (if you are interested in NumberPicker, you can find the package here). After a weight is picked I update state with it. As a leading image I added own asset from www.materialdesignicons.com.

4.3 Adding a note

@override
void initState() {
  _textEditingController = new TextEditingController(text: _note);
  super.initState();
}
new ListTile(
  leading: new Icon(Icons.speaker_notes, color: Colors.grey[500]),
  title: new TextField(
    decoration: new InputDecoration(
      hintText: 'Optional note',
    ),
    controller: _textEditingController,
    onChanged: (value) => _note = value,
  ),
),

To add a note I decided to go with simple TextField. I am not sure if this is the best solution to do this but it is enough to me so I will stay with it 🙂 On initState we create a TextEditingController just to build TextField with initial value of _note if it exists. Additionally, we update _note value every time TextField is editted.

5. End note

What is worth mentioning I didn’t create confirmation dialog if user exits a dialog without saving data. I simply decided that it is not needed in my case.

I also decided to add an option to both create and edit weight entry since it’s pretty easy to do. Using name constructors, I introduced two ways of using WeightEntryDialog, so we can provide old entry and populate dialog data with it or we can start by creating a new one.

I hope you liked that post. Creating full-screen dialog in Flutter seems very easy and I am really glad that Flutter team has created tools for that. If you have any questions or you think you could improve my solutions, please leave a comment, I will gladly discuss them 🙂

That’s it folks!

You can find whole project here.

Leave a Reply

Your email address will not be published.