© Frank Zammetti 2019
Frank ZammettiPractical Flutterhttps://doi.org/10.1007/978-1-4842-4972-7_6

6. FlutterBook, Part II

Frank Zammetti1 
(1)
Pottstown, PA, USA
 

In the last chapter, we began looking at the code of FlutterBook, the notes entity specifically. In this chapter, we’ll close it out by looking at the tasks, appointments, and contacts.

That may seem like a lot of ground to cover, but here’s the secret of why it’s not: if you compare the code for the four entities, you’ll see that they are probably 90% the same. The same structure is at play for all of them: a main code file (like notes.dart) and then a list screen and an entry screen, each in their own source files. The code in each will mostly be the same (or extremely similar) to that of the notes entity. The four list screens are all somewhat different though, so we’ll be looking at those in some detail, but the entry screens are, for the most part, very similar, save for a few bits and pieces.

So, what I’m going to do is only show you the areas where things diverge from the code you saw in the last chapter. As such, we’ll be looking at pieces of source files for the most part, not full source files. The bottom line is if I don’t discuss it here then you can assume that it’s not really any different than the notes code from the previous chapter (aside from small things like variable names and field names and such, the obvious things that don’t have much impact to your learning).

Get ‘Er Done: Tasks

The first entity we’ll look at in this chapter is tasks. Tasks are straightforward things: they only require a line of text to describe them and optionally a due date. As you saw in the screenshot from the previous chapter, the list view allows the user to check off the tasks they have completed. As such, the code is quite simple, arguably even simpler than the notes code.

TasksModel.dart

First, as you know, each entity has its own model, and a class that represents an instance of the entity. Tasks are no different: there is a Task class, and the only difference between the Note class from the last chapter and Task are the fields in the class:
int id;
String description;
String dueDate;
String completed = "false";

As usual, an instance of this class will be stored in the database, and so we need a unique id field. Beyond that, we have the description of the task, a dueDate (which will be optional) and a completed flag to tell if the task is done or not. You may think that completed should be a bool, and I would generally agree! But, since it’s getting stored in a SQLite table, and SQLite doesn’t offer us a boolean type natively, it’ll have to be stored as a string. While converting from and to string when necessary so we’re dealing with a bool in the Dart code would be doable, I don’t see much point to it in this case, so a string it is!

After that comes the model:
class TasksModel extends BaseModel { }

Wait, did I make a mistake pasting the code in this chapter? Nope! The TasksModel really is empty! You see, there are no fields on the entry screen like there were with notes (dealing with the selected color) that we need to track on the screen. Therefore there doesn’t have to be anything in the model. Recall that BaseModel provides the common code that all four of the models need to have, but tasks don’t need anything beyond that; hence its just an empty object (empty aside from what BaseModel provides that is!).

TasksDBWorker.dart

Like notes, the tasks entity needs to have its database worker, but there’s only one substantive thing different from the notes worker (again, aside from basic things like variable and method names and such), and that’s the SQL executed to create the table for this entity:
CREATE TABLE IF NOT EXISTS tasks (
  id INTEGER PRIMARY KEY, description TEXT,
  dueDate TEXT, completed TEXT
)

Hopefully, that’s exactly what you expected!

Tasks.dart

The starting point for the task entity’s screen is, like TasksDBWorker.dart, nearly identical to Notes.dart that you saw in the last chapter, the same screen structure with the IndexedStack and all that is present here, so let’s move on to some code that actually has some differences to see, shall we?

TasksList.dart

As mentioned earlier, each of the four list views is a bit different from one another, though even there you’ll find that a large percentage of the code is identical. But, for tasks, the primary difference is that tasks can be checked off when completed, so let’s see the widget returned by the build() function here, which is again a ScopedModel wrapping a Scaffold and with the following body:
body : ListView.builder(
  padding : EdgeInsets.fromLTRB(0, 10, 0, 0),
  itemCount : tasksModel.entityList.length,
  itemBuilder : (BuildContext inBuildContext, int inIndex) {
    Task task = tasksModel.entityList[inIndex];
    String sDueDate;
    if (task.dueDate != null) {
      List dateParts = task.dueDate.split(",");
      DateTime dueDate = DateTime(int.parse(dateParts[0]),
        int.parse(dateParts[1]), int.parse(dateParts[2]));
      sDueDate = DateFormat.yMMMMd(
        "en_US"
      ).format(dueDate.toLocal());
    }

The due date, if there is one, gets split() into three individual parts (remember from the previous chapter that it’s stored as “year,month,day”) and those parts passed to the DateTime constructor to get a DateTime object for the specified due date. Then, we use one of the formatting functions that the DateFormat class offers. This class is a utility class from the intl package that provides numerous functions for formatting dates and times and dealing with other internationalization concerns. The intricacies of working with these functions is a little beyond our scope here. But, the bottom line is that calling the yMMMMD() function and then feeding the return value to the format() function and passing it the result of a toLocal() call on the dueDate DateTime object gives us back a nicely formatted version of the date suitable for display. And that’s the whole point of this exercise!

Next, we can start building the UI which, like notes, uses a Slidable as the basis:
    return Slidable(delegate : SlidableDrawerDelegate(),
      actionExtentRatio : .25, child : ListTile(
        leading : Checkbox(
          value : task.completed == "true" ? true : false,
          onChanged : (inValue) async {
            task.completed = inValue.toString();
            await TasksDBWorker.db.update(task);
            tasksModel.loadData("tasks", TasksDBWorker.db);
          }
        ),

This time though, the leading is where we find the Checkbox the user can check when the task is completed. The value is taken from the task reference, which as you can see is the next task in the list of tasks. Since completed is a string and not a boolean, it can’t be the value of the value property directly, so a simple ternary expression gets the boolean that is needed. After that, we have to attach an onChanged event handler for when the Checkbox is checked (or unchecked). The work here is easy: take the boolean value passed into the onChanged function and set it as the value of task.completed by calling toString() on it. Then, ask TasksDBWorker to update the task, and finally tell the TasksModel to rebuild the list via a call to the loadData() method that you know is supplied by BaseModel. That’s it, easy!

Continuing on, we have the rest of the configuration for the Slidable:
        title : Text(
          "${task.description}",
          style : task.completed == "true" ?
            TextStyle(color :
              Theme.of(inContext).disabledColor,
              decoration : TextDecoration.lineThrough
            ) :
           TextStyle(color :
             Theme.of(inContext).textTheme.title.color
           )
        ),
        subtitle : task.dueDate == null ? null :
          Text(sDueDate,
            style : task.completed == "true" ?
              TextStyle(color :
                Theme.of(inContext).disabledColor,
                decoration : TextDecoration.lineThrough) :
              TextStyle(color :
                Theme.of(inContext).textTheme.title.color)
          ),
        onTap : () async {
          if (task.completed == "true") { return; }
          tasksModel.entityBeingEdited =
            await TasksDBWorker.db.get(task.id);
          if (tasksModel.entityBeingEdited.dueDate == null) {
            tasksModel.setChosenDate(null);
          } else {
            tasksModel.setChosenDate(sDueDate);
          }
          tasksModel.setStackIndex(1);
        }
      ),
      secondaryActions : [
        IconSlideAction(
          caption : "Delete",
          color : Colors.red,
          icon : Icons.delete,
          onTap : () => _deleteTask(inContext, task)
        )
      ]
    );
  }
)

That should all look pretty familiar to you given your exposure to the code for notes, but one thing to understand is that we don’t allow the user to edit a task that is already completed, hence the check of task.completed in the onTap() event handler.

As with notes, there is a deleteTask() method here too that you can see called via the secondaryActions of the Slidable, but given that it’s the same as for notes, we can skip looking at it and move on to the entry screen.

TasksEntry.dart

The entry screen, which you can see in Figure 6-1, is very sparse, as previously stated, just having two fields, only one of which (description) is required:
../images/480328_1_En_6_Chapter/480328_1_En_6_Fig1_HTML.jpg
Figure 6-1

The entry screen for tasks

The only code we need to look at here is around the due date field:
ListTile(leading : Icon(Icons.today),
  title : Text("Due Date"), subtitle : Text(
    tasksModel.chosenDate == null ? "" : tasksModel.chosenDate
  ),
  trailing : IconButton(
    icon : Icon(Icons.edit), color : Colors.blue,
    onPressed : () async {
      String chosenDate = await utils.selectDate(
        inContext, tasksModel,
        tasksModel.entityBeingEdited.dueDate);
      if (chosenDate != null) {
        tasksModel.entityBeingEdited.dueDate = chosenDate;
      }
    }
  )
)

Here, we finally see the usage of that utils.selectDate() function that we looked at briefly in the last chapter. This function returns a string in the form “year,month,day” after the user selects a date, which you know is the form it’s saved into the database. Of course, if null is returned, then no date was selected, so we only set the dueDate field of the task if null wasn’t returned.

And that’s it for tasks!

Make a Date: Appointments

Next up is the appointments entity, and here we have a few new things to look at, including a nice plugin for our main display on the list screen. But, before that, let’s take a look at the model.

AppointmentsModel.dart

As with the tasks entity and note entity, we have a class to describe an appointment, not surprisingly named Appointment, and it has the following fields:
int id;
String title;
String description;
String apptDate;
String apptTime;

The id you know about and title and description are obvious. Like task, an appointment has a date, called apptDate this time, but unlike tasks, appointments also have a time, stored as apptTime. Both are strings again because that’s how they ultimately get stored in the database, so naturally, we’re going to have some conversion code somewhere, as you’ll see soon.

After the class definition comes the model, and there’s only a single method we need to add to it, the one dealing with the time and being able to display it on the entry screen:
class AppointmentsModel extends BaseModel {
  String apptTime;
  void setApptTime(String inApptTime) {
    apptTime = inApptTime;
    notifyListeners();
  }
}

As with tasks (and contacts, as you’ll see later), the BaseModel chosenDate field and setChosenDate() method will be used for the appointment’s date on the entry screen (because that’s needed on multiple entry screens, putting it in BaseModel avoids some duplicate code). Since only appointments have a time though, we only need the apptTime and setApptTime() method in this model, but the code for the setApptTime() method is just like that of setChosenDate(): store the passed in value in the model and notify listeners, so the screen is rebuilt with the new value.

AppointmentsDBWorker.dart

As with notes and tasks, appointments have a database worker, and it’s nearly identical to those two except for the table definition, which is as follows:
CREATE TABLE IF NOT EXISTS appointments (
  id INTEGER PRIMARY KEY, title TEXT,
  description TEXT, apptDate TEXT, apptTime TEXT
)

There are no other surprises here; you’ve essentially seen this already by virtue of seeing the database worker for notes, so let’s go forward.

Appointments.dart

Again, like with tasks, the core screen definition for appointments is the same as that of notes, so we can jump into the list screen straight away, which does have some new stuff to see.

AppointementsList.dart

First up, we have some new imports, among a batch of others you’ve seen before:
import
  "package:flutter_calendar_carousel/"
  "flutter_calendar_carousel.dart";
import "package:flutter_calendar_carousel/classes/event.dart";
import
  "package:flutter_calendar_carousel/classes/event_list.dart";

The Calendar Carousel is a plugin (see here: https://pub.dartlang.org/packages/flutter_calendar_carousel ) that provides to our app a calendar widget that can be swiped horizontally to move between months. It has many other options available to it such as showing indicators on dates that have events on them, a variety of display modes, and tap handlers to perform actions when a date is tapped.

All of which sounds like precisely the kind of thing we need for displaying appointments in something other than a simple list! Flutter doesn’t offer such a thing out of the box, hence the need for a plugin, and Calendar Carousel fits the bill exactly.

So, let’s start building the list screen widget and see how it’s used:
class AppointmentsList extends StatelessWidget {
  Widget build(BuildContext inContext) {
    EventList<Event> _markedDateMap = EventList();
    for (
      int i = 0; i < appointmentsModel.entityList.length; i++
    ) {
      Appointment appointment =
        appointmentsModel.entityList[i];
      List dateParts = appointment.apptDate.split(",");
      DateTime apptDate = DateTime(
        int.parse(dateParts[0]), int.parse(dateParts[1]),
        int.parse(dateParts[2]));
      _markedDateMap.add(apptDate, Event(date : apptDate,
        icon : Container(decoration : BoxDecoration(
          color : Colors.blue))
      ));
    }
One of the things Calendar Carousel provides is a way to show some sort of indicator on dates that have events associated with them. To do this, it has a markedDatesMap property that accepts a value that is a map containing keys that are DateTime objects and corresponding values that are Event objects (the latter being a class it provides) that describe each event. When the calendar is rendered, it uses this map to show the indicators. Here, we’re building up that map by iterating over the appointmentsModel.entityList, which you know from your experience with notes, since the logic is the same here, is an array of appointments retrieved from the database. For each, we split the apptDate property and then feed that to the DateTime constructor to get a DateTime instance for the appointment’s date. Then, we construct an Event object and add it to the _markedDateMap map. The Event object takes the date in of course, and it also takes an icon property. This can be any widget you want, and it will wind up being the indicator shown on the date. Here, I use a simple Container widget that has a BoxDecoration as a decoration . With no further properties defined for the BoxDecoration or the Container, the result is that the box uses the minimum space it can, the result being a square just a few pixels wide and tall, as you can see in Figure 6-2.
../images/480328_1_En_6_Chapter/480328_1_En_6_Fig2_HTML.jpg
Figure 6-2

The appointments list screen with date indicators

The 8th and 13th have appointments, hence the dots. Notice on the 13th that if there are multiple events, you’ll get multiple dots. Perfect!

Now, we can move on to the widget returned from this build() method :
return ScopedModel<AppointmentsModel>(
  model : appointmentsModel,
  child : ScopedModelDescendant<AppointmentsModel>(
    builder : (inContext, inChild, inModel) {
      return Scaffold(
        floatingActionButton : FloatingActionButton(
          child : Icon(Icons.add, color : Colors.white),
          onPressed : () async {
            appointmentsModel.entityBeingEdited =
              Appointment();
            DateTime now = DateTime.now();
            appointmentsModel.entityBeingEdited.apptDate =
              "${now.year},${now.month},${now.day}";
            appointmentsModel.setChosenDate(
              DateFormat.yMMMMd("en_US").format(
                now.toLocal()));
            appointmentsModel.setApptTime(null);
            appointmentsModel.setStackIndex(1);
          }
        ),
It starts out the same as notes and tasks did, complete with a FAB for creating a new appointment. At this point, that code should be quite familiar, so no need to go over in detail again. Instead, let’s see what comes next:
body : Column(
  children : [
    Expanded(
      child : Container(
        margin : EdgeInsets.symmetric(horizontal : 10),
        child : CalendarCarousel<Event>(
          thisMonthDayBorderColor : Colors.grey,
          daysHaveCircularBorder : false,
          markedDatesMap : _markedDateMap,
          onDayPressed :
            (DateTime inDate, List<Event> inEvents) {
              _showAppointments(inDate, inContext);
            }
        )
      )
    )
  ]
)

The goal here is to get the Calendar Carousel to expand to fill the screen. For that, an Expanded widget makes sense since that’s expressly its purpose: it expands its child to fill the available space inside a Row, Column, or Flex. The important point is that the parent widget must have a flex capability, which limits it to those three shown. Which of those is used here wouldn’t matter much given that this is the only widget on the screen (well, in the body more precisely), so I just went with the tried and true Column. Rather than put the CalendarCarousel widget directly as the child of the Expanded though, I put it inside a Container so that I could set some margin around it. I just felt it looked better not stretching all the way to the very edge of the screen, not to mention avoiding running into the TabBar at the top or the FAB at the bottom.

The CalendarCarousel itself is simple to define in our case (though it provides many configuration options, we only need a handful). I give each date a grey border, as well as ensuring they are square by setting daysHaveCircularBorder to false. Then, the markedDatesMap we talked about earlier is pointed to the _markedDateMap that was populated before. Finally, an event handler is hooked up to handle taps on the dates. When this occurs, I want to show the events for the selected date, if any, in a BottomSheet, thanks to the _showAppointments() method :
void _showAppointments(
  DateTime inDate, BuildContext inContext) async {
  showModalBottomSheet(context : inContext,
    builder : (BuildContext inContext) {
      return ScopedModel<AppointmentsModel>(
        model : appointmentsModel,
        child : ScopedModelDescendant<AppointmentsModel>(

This method starts by accepting the DateTime that was tapped, and the BuildContext associated with the widget that called it. Note that while the function defined for onDayPressed accepts a list of events, for the display to show what I want, I needed to get that data from the entityList in the model. That’s because the Event objects passed in wouldn’t have all the data I needed, so that argument is simply ignored here in favor of the code that follows. That’s why the widget returned by the builder function of the showModalBottomSheet() call begins with a ScopedModel and references our AppointmentsModel.

The builder function comes next:
builder : (BuildContext inContext, Widget inChild,
  AppointmentsModel inModel) {
  return Scaffold(
    body : Container(child : Padding(
      padding : EdgeInsets.all(10), child : GestureDetector(

So far, nothing you haven’t seen before, right? I again felt like some padding was in order here, so a Container starts off the body so that I could apply it.

After that, since what is shown in the BottomSheet, which you can see in the screenshot from the previous chapter, is a vertically scrolling list of appointments, a Column layout makes sense here:
child : Column(
  children : [
    Text(DateFormat.yMMMMd("en_US").format(inDate.toLocal()),
      textAlign : TextAlign.center,
      style : TextStyle(color :
        Theme.of(inContext).accentColor, fontSize : 24)
    ),
    Divider(),

The first child is just a simple Text element, centered on the BottomSheet via the textAlign property, and the value of which is the selected date, formatted nicely. Notice here the way the color of the text is retrieved: the Theme.of() function is always available and gets you a reference to the theme currently active for the app. Once you have that reference, you can access its members, one of which is accentColor, which for the default theme will be a nice shade of blue. The fontSize is also specified to make this text stand out from the rest. I also added a Divider widget after that to separate the date from the list of appointments.

After that comes another Expanded, so that the list of appointments will fill the remaining space inside the Column layout. Each child is then built from the entityList, which we must write some code to filter out any appointment not for the selected date:
Expanded(
  child : ListView.builder(
    itemCount : appointmentsModel.entityList.length,
    itemBuilder : (BuildContext inBuildContext, int inIndex) {
      Appointment appointment =
        appointmentsModel.entityList[inIndex];
      if (appointment.apptDate !=
        "${inDate.year},${inDate.month},${inDate.day}") {
        return Container(height : 0);
      }
      String apptTime = "";
      if (appointment.apptTime != null) {
        List timeParts = appointment.apptTime.split(",");
        TimeOfDay at = TimeOfDay(
          hour : int.parse(timeParts[0]),
          minute : int.parse(timeParts[1]));
        apptTime = " (${at.format(inContext)})";
      }

For any appointment that’s not for the selected date, we return a Container with a zero height. This is necessary because returning null from the itemBuilder function will result in an exception; Flutter expects something to be returned in all cases, so here it’s something that won’t display anything, so only appointments for this date will be visible in the end, just like we want.

The time of the appointment, if there is one, has to be split just like the date is, because it’s stored as a string in “hh,mm” form in the database. Once that’s done, we can pass those two pieces of information to the TimeOfDay constructor , which is just like DateTime but, obviously, for times! The format() method of TimeOfDay gives us back a nicely formatted time in the applicable local form.

Now, each appointment needs to be editable and deletable, and we’ve been doing that with the Slidable widget so far with other entities, and it’s the same story here:
return Slidable(delegate : SlidableDrawerDelegate(),
  actionExtentRatio : .25, child : Container(
  margin : EdgeInsets.only(bottom : 8),
    color : Colors.grey.shade300,
    child : ListTile(
      title : Text("${appointment.title}$apptTime"),
      subtitle : appointment.description == null ?
        null : Text("${appointment.description}"),
      onTap : () async {
        _editAppointment(inContext, appointment);
      }
    )
  ),
If the appointment has a description, then that’s shown as the subtitle text. Otherwise, the value there is null, and nothing is displayed. The _editAppointment method we’ll look at shortly, but before that, we’ll finish out the widget definition with the secondaryActions property of the Slidable:
secondaryActions : [
  IconSlideAction(caption : "Delete", color : Colors.red,
    icon : Icons.delete,
    onTap : () =>
      _deleteAppointment(inBuildContext, appointment)
  )
]
That’s no different than what you saw for notes and tasks already, and in fact, the code of the _deleteAppointment() method is just like the delete method for those two entities, so we’ll skip looking at it. However, we do still have that _editAppointment() method to look at, and it is this code:
void _editAppointment(BuildContext inContext, Appointment
  inAppointment) async {
  appointmentsModel.entityBeingEdited =
    await AppointmentsDBWorker.db.get(inAppointment.id);
  if (appointmentsModel.entityBeingEdited.apptDate == null) {
    appointmentsModel.setChosenDate(null);
  } else {
    List dateParts =
      appointmentsModel.entityBeingEdited.apptDate.split(",");
    DateTime apptDate = DateTime(
      int.parse(dateParts[0]), int.parse(dateParts[1]),
      int.parse(dateParts[2]));
    appointmentsModel.setChosenDate(
      DateFormat.yMMMMd("en_US").format(apptDate.toLocal()));
  }
  if (appointmentsModel.entityBeingEdited.apptTime == null) {
    appointmentsModel.setApptTime(null);
  } else {
    List timeParts =
      appointmentsModel.entityBeingEdited.apptTime.split(",");
    TimeOfDay apptTime = TimeOfDay(
      hour : int.parse(timeParts[0]),
      minute : int.parse(timeParts[1]));
    appointmentsModel.setApptTime(apptTime.format(inContext));
  }
  appointmentsModel.setStackIndex(1);
  Navigator.pop(inContext);
}

It too is nearly identical to its notes and tasks edit method counterparts like the delete code is, but here we must deal with the time. Since the date and time are optional for appointments, we have to check if they are null or not and only process them if they aren’t. If they aren’t, we have to parse out the date components and build a DateTime to pass to setChosenDate() in the model, and similarly, we have to parse out the time components to construct a TimeOfDay to pass to setApptTime().

AppointmentsEntry.dart

The final piece of the appointment puzzle is, of course, the entry screen, which is shown here in Figure 6-3.
../images/480328_1_En_6_Chapter/480328_1_En_6_Fig3_HTML.jpg
Figure 6-3

The entry screen for appointments

It’s a simple enough screen, just a single required field in the title, a multi-line description field, and two fields for selecting a date and time. Technically, Date is required as well because an appointment without a date wouldn’t make much sense (though time is not required because an appointment doesn’t necessarily have to have a time associated with it logically). But, instead of dealing with validation, the date gets set to the current date by default, and there’s no way to clear it. Therefore, an appointment is automatically going to always have a date, and it’s up to the user to change it if the current date isn’t appropriate.

As far as the code goes, there’s nothing new there except for the piece about getting an appointment time, which is very similar to the date part, but let’s take a look at the time code now anyway, beginning with the field definition:
      ListTile(leading : Icon(Icons.alarm),
        title : Text("Time"),
        subtitle : Text(appointmentsModel.apptTime == null ?
          "" : appointmentsModel.apptTime),
        trailing : IconButton(
          icon : Icon(Icons.edit), color : Colors.blue,
          onPressed : () => _selectTime(inContext)
        )
      )
    ]
  )
)
That’s a perfectly ordinary field definition, just like the others you’ve seen so far, save for the call to _selectTime in the onPressed handler, which is as follows:
Future _selectTime(BuildContext inContext) async {
  TimeOfDay initialTime = TimeOfDay.now();
  if (appointmentsModel.entityBeingEdited.apptTime != null) {
    List timeParts =
      appointmentsModel.entityBeingEdited.apptTime.split(",");
    initialTime = TimeOfDay(hour : int.parse(timeParts[0]),
      minute : int.parse(timeParts[1])
     );
  }
  TimeOfDay picked = await showTimePicker(
    context : inContext, initialTime : initialTime);
  if (picked != null) {
    appointmentsModel.entityBeingEdited.apptTime =
      "${picked.hour},${picked.minute}";
    appointmentsModel.setApptTime(picked.format(inContext));
  }
}

Like I said, very similar to the code for getting a date that you saw earlier for tasks. If the user is editing an existing appointment, then we have to set the initialTime of the TimePicker that we’ll show via the call to showTimePicker(). Therefore, we have to parse it out of the model, the entityBeingEdited specifically, since the list screen will have set that before navigating to the entry screen. Then, once the user selects a time, or cancels the TimePicker, the apptTime is updated in entityBeingEdited and then also in the model via the call to setApptTime() so that it gets reflected on the screen (remember, that method will call notifyListeners(), which will trigger Flutter updating the display based on the new model value).

Reaching Out: Contacts

The final entity to look at is contacts, and I left it for last because in some ways it’s the most complex of the bunch, and for sure it offers you the opportunity to see some new things.

ContactsModel.dart

As with the other three entities, we’ll start with the model for it, including the Contact class. And, like the other three entities, I’ll just show you the fields in the class since it is otherwise the same as those others:
int id;
String name;
String phone;
String email;
String birthday;

A contact can, of course, have a great deal of information stored about it – if you open your phone’s contact app right now, you’ll see a whole host of attributes you can set – but I’ve chosen probably the key ones only, plus one more just for fun! The name, phone, and email fields are obviously key to a contact in this day and age, and I added birthday just to have another example of working with dates and the DatePicker.

Tip

I recommend extending the three apps in this book as learning exercises. All of them have things that can be done to make them better, by design for the most part, and adding more fields for contacts would be one relatively easy thing you could do to practice your skills.

As for the model, there’s only one thing we need to have in it:
class ContactsModel extends BaseModel {
  void triggerRebuild() {
    notifyListeners();
  }
}

The birthday will be covered by the chosenDate field (and its associated setter method) from BaseModel, so this one triggerRebuild() method really is all that’s specific to contacts. Since notifyListeners() must be called from within the model class, that’s why we need this method, but in this case, that’s the only task it has to accomplish. As you’ll see, this method will be used when editing a contact, and an avatar image is selected so that the image gets displayed on the screen. Let’s not get ahead of ourselves though; we’ll get to that soon.

ContactsDBWorker.dart

The database worker code for contacts is once again identical to the three you’ve already seen, save for the creation SQL as usually, so here is that:
CREATE TABLE IF NOT EXISTS contacts (
  id INTEGER PRIMARY KEY,
  name TEXT, email TEXT, phone TEXT, birthday TEXT
)

By now, that shouldn’t hold any surprises for you.

Contacts.dart

Similarly, the base layout of the contacts screen has nothing new to offer compared to the code for the other three entities, so let’s get to where there is some new stuff.

ContactsList.dart

The list screen for contacts is just a simple ListView, much like you’ve seen in other areas, but we have an avatar image to deal with, potentially, for each contact, and that requires some new Flutter bits:
return ScopedModel<ContactsModel>(
  model : contactsModel,
  child : ScopedModelDescendant<ContactsModel>(
    builder : (BuildContext inContext, Widget inChild,
      ContactsModel inModel) {
      return Scaffold(
        floatingActionButton : FloatingActionButton(
          child : Icon(Icons.add, color : Colors.white),
          onPressed : () async {
            File avatarFile =
              File(join(utils.docsDir.path, "avatar"));
            if (avatarFile.existsSync()) {
              avatarFile.deleteSync();
            }
            contactsModel.entityBeingEdited = Contact();
            contactsModel.setChosenDate(null);
            contactsModel.setStackIndex(1);
          }
        )

It all starts off ordinarily enough: the usual ScopedModel at the top, with the model referencing contactsModel, and then a child that is a ScopedModelDescendant. The builder function then is present, and it returns a Scaffold, which we need so we can have a FAB for creating a new contact.

Now, the onPressed event handler of the FAB is where we start to see some new and exciting stuff. What you’re going to see is that when a contact is created, you can add an avatar image to it. The image will be stored in the app’s documents directory, not in the database (that’s on purpose because it provides an opportunity for you to see some file handling code). But, when editing a contact, whether new or existing, a temporary image file can be present if the user had previously been editing a contact. So, to start when creating a new contact, we have to make sure that the temporary file isn’t there. The File class is a Dart class from the io package, and its constructor takes as an argument a path to a file. You saw the utils.docsDir retrieved in the previous chapter, and its path property is the path to the documents directory. So, passing that to the join() method , which is a function provided by the path library that knows how to concatenate file path parts to wind up with a proper platform-dependent path, along with the filename avatar, gets us a reference to that file, if it exists, wrapped in a File instance. The File class provides some methods, one of which is existsSync(). This returns true if the file exists, false if not, and it does so synchronously, which we need here. Otherwise, we’d have to await it (or otherwise wait for a Future to be resolved). There is also an exists() version that is asynchronous. If it exists then the deleteSync() method is called to get rid of it (and there is an asynchronous delete() method available as well). After that, a new Contact is created, and the user is navigated to the entry screen as usual.

Next up we have the ListView that contains the contacts:
body : ListView.builder(
  itemCount : contactsModel.entityList.length,
  itemBuilder : (BuildContext inBuildContext, int inIndex) {
    Contact contact = contactsModel.entityList[inIndex];
    File avatarFile =
      File(join(utils.docsDir.path, contact.id.toString()));
    bool avatarFileExists = avatarFile.existsSync();
Each contact is pulled out of the model in turn, and a reference to its avatar file is created, if it exists. The file uses the contact’s id as a filename, so it’s an easy link to the contact. This time, the result of the call to existsSync() is stored in avatarFileExists, for a reason you can see in the next chunk of code:
return Column(children : [
  Slidable(
    delegate : SlidableDrawerDelegate(),
    actionExtentRatio : .25, child : ListTile(
    leading : CircleAvatar(
      backgroundColor : Colors.indigoAccent,
      foregroundColor : Colors.white,
      backgroundImage : avatarFileExists ?
        FileImage(avatarFile) : null,
      child : avatarFileExists ? null :
        Text(contact.name.substring(0, 1).toUpperCase())
    ),
    title : Text("${contact.name}"),
    subtitle : contact.phone == null ?
      null : Text("${contact.phone}"),

Each child of the ListView is a Column layout and will have two items in it: a Slidable that contains a contact itself and a Divider, hence the Column being necessary. The Slidable is like all the others you’ve seen except for the leading. Here, it’s a CircleAvatar , which is a widget that shows an image and condenses it down into a circular shape. It’s typically used to display avatar images of people in a list, so it’s a very fitting widget to use here. The only trick here is that the backgroundImage, which is how the image is specified, must be either a valid FileImage reference or null. That’s where that avatarFileExists flag comes in. When it’s true, the avatarFile, which remember is a File instance, is wrapped in a FileImage widget, which is a widget to display an image based on a reference to a file on the file system. When it’s false, then backgroundImage will be null.

We also need that flag because when a contact has no avatar image, we want to show the first letter of their name, which is a typical pattern in contact apps. So, the child of the CircleAvatar will either be null when there is an image, or it will be a Text widget when it doesn’t. In the latter case, the substring() method of the String class, of which contact.name is, of course, an instance, is used to get that first letter, and the toUpperCase() method is used to ensure its upper-case.

The rest of the configuration for the Slidable you already know, so let’s look at the onTap handler for it, which is how we trigger editing of a contact:
  onTap : () async {
    File avatarFile =
      File(join(utils.docsDir.path, "avatar"));
    if (avatarFile.existsSync()) {avatarFile.deleteSync(); }
    contactsModel.entityBeingEdited =
      await ContactsDBWorker.db.get(contact.id);
    if (contactsModel.entityBeingEdited.birthday == null) {
      contactsModel.setChosenDate(null);
    } else {
      List dateParts =
        contactsModel.entityBeingEdited.birthday.split(",");
      DateTime birthday = DateTime(
        int.parse(dateParts[0]), int.parse(dateParts[1]),
        int.parse(dateParts[2]));
      contactsModel.setChosenDate(
        DateFormat.yMMMMd("en_US").format(birthday.toLocal())
      );
    }
    contactsModel.setStackIndex(1);
  }

This handler too is not too different from the others you’ve seen, but here again, we have to deal with the temporary avatar image that could be there, so that’s deleted if it exists. The date has to be parsed apart too and set in the model for display on the edit screen, and then the usual screen navigation is done via the call to setStackIndex().

Just to complete the Slidable and ListView configuration, here’s the secondaryActions:
  secondaryActions : [
    IconSlideAction(caption : "Delete", color : Colors.red,
      icon : Icons.delete,
      onTap : () => _deleteContact(inContext, contact))
  ]
),
Divider()

You can also see the Divider there, and that completes the return in the itemBuilder() function .

Now, let’s see about deleting a contact:
Future _deleteContact(BuildContext inContext,
  Contact inContact) async {
  return showDialog(context : inContext,
    barrierDismissible : false,
    builder : (BuildContext inAlertContext) {
      return AlertDialog(title : Text("Delete Contact"),
        content : Text(
          "Are you sure you want to delete ${inContact.name}?"
        ),
        actions : [
          FlatButton(child : Text("Cancel"),
            onPressed: () {
              Navigator.of(inAlertContext).pop();
            }
          ),
  FlatButton(child : Text("Delete"),
    onPressed : () async {
      File avatarFile = File(
        join(utils.docsDir.path, inContact.id.toString()));
      if (avatarFile.existsSync()) {
        avatarFile.deleteSync();
      }
      await ContactsDBWorker.db.delete(inContact.id);
      Navigator.of(inAlertContext).pop();
      Scaffold.of(inContext).showSnackBar(
        SnackBar(backgroundColor : Colors.red,
          duration : Duration(seconds : 2),
          content : Text("Contact deleted")));
      contactsModel.loadData("contacts", ContactsDBWorker.db);
    }
  )

Most of this is what by now can rightly be called the typical code of an entity delete function, but once more, we have the avatar files to deal with. Deleting a contact from the database isn’t sufficient; we have to delete its avatar file too if it has one, so once more we get a reference to it and, if it exists, call deleteSync() to get rid of it. After that, it’s just the usual database deletion and showing a SnackBar code to confirm, and we’re all done!

ContactsEntry.dart

We have just one more piece of FlutterBook to look and, and it’s the contacts entry screen, which you can glimpse in Figure 6-4.
../images/480328_1_En_6_Chapter/480328_1_En_6_Fig4_HTML.jpg
Figure 6-4

The entry screen for contacts

It’s a simple enough screen: three TextFormField widgets , only one of which (name) is required, and then a birthday field with a trigger icon to show a DatePicker. As such, we’ll get through this pretty quick, but I want to show the code anyway because the stuff about the avatar images is mixed in a few places, and that’s where this code substantively diverges from the entry screen code for the other three entities a bit.
return ScopedModel(model : contactsModel,
  child : ScopedModelDescendant<ContactsModel>(
    builder : (BuildContext inContext, Widget inChild,
      ContactsModel inModel) {
      File avatarFile =
        File(join(utils.docsDir.path, "avatar"));
      if (avatarFile.existsSync() == false) {
        if (inModel.entityBeingEdited != null &&
          inModel.entityBeingEdited.id != null
        ) {
          avatarFile = File(join(utils.docsDir.path,
            inModel.entityBeingEdited.id.toString()
          ));
        }
      }
The first thing to deal with is the fact that this screen can be shown when creating a new contact or when editing an existing one. In the create case, there will be no avatar image, but when editing there might be: remember that build() will be called when the model changes, which is precisely what will happen when the user selects an avatar. So, being as we’re inside the build() method here, we have to see if there is a temporary avatar image. If there isn’t one, then we check the entityBeingEdited. If it has an id, which is only true when editing a contact, then we try to get a reference to its actual avatar file (as opposed to the file literally named avatar, which is the temporary one). We capture a reference to this for later. It will be needed when we start rendering fields, but first, we have some other “preliminary” stuff to do:
return Scaffold(bottomNavigationBar : Padding(
  padding :
    EdgeInsets.symmetric(vertical : 0, horizontal : 10),
  child : Row(
    children : [
      FlatButton(child : Text("Cancel"),
        onPressed : () {
          File avatarFile =
            File(join(utils.docsDir.path, "avatar"));
          if (avatarFile.existsSync()) {
            avatarFile.deleteSync();
          }
          FocusScope.of(inContext).requestFocus(FocusNode());
          inModel.setStackIndex(0);
        }
      ),
      Spacer(),
      FlatButton(child : Text("Save"),
        onPressed : () { _save(inContext, inModel); })
    ]
  )),

This is a typical entry form start, but in the Cancel button’s onPressed handler we have some work to do around the possible temporary avatar file. Even though it’s deleted before this screen is shown, it’s still better to delete it now, if it exists (if the user had selected an avatar but then cancelled), so that’s exactly what we do. Once that’s done, the soft keyboard is hidden as previously discussed, and the user navigated back to the list screen. The Save button just calls _save() like always, and we’ll see that later.

Before that though, let’s start defining the actual form:
body : Form(key : _formKey, child : ListView(
  children : [
    ListTile(title : avatarFile.existsSync() ?
      Image.file(avatarFile) :
      Text("No avatar image for this contact"),
      trailing : IconButton(icon : Icon(Icons.edit),
        color : Colors.blue,
        onPressed : () => _selectAvatar(inContext)
      )
    )

Now, you can see where that avatarFile reference comes into play: the title of the ListTile will be either an Image or it will be a Text widget saying that no avatar image has been selected. When it’s an Image, the avatarFile is passed to the Image.file() constructor and the avatar image is shown. Note that I did nothing fancy with scaling or constraints here. It will simply display the image in whatever size it is (you may want to change that as a suggested exercise – hint hint!). The trailing property of the ListTile provides an IconButton for the user to click to select an avatar image, and the code for this is something we’ll look at soon because it’s got some interesting new stuff to see!

First though, let’s continue defining the form:
ListTile(leading : Icon(Icons.person),
  title : TextFormField(
    decoration : InputDecoration(hintText : "Name"),
    controller : _nameEditingController,
    validator : (String inValue) {
      if (inValue.length == 0) {
        return "Please enter a name";
      }
      return null;
    }
  )
),
ListTile(leading : Icon(Icons.phone),
  title : TextFormField(
    keyboardType : TextInputType.phone,
    decoration : InputDecoration(hintText : "Phone"),
    controller : _phoneEditingController)
),
ListTile(leading : Icon(Icons.email),
  title : TextFormField(
    keyboardType : TextInputType.emailAddress,
    decoration : InputDecoration(hintText : "Email"),
    controller : _emailEditingController)
),
ListTile(leading : Icon(Icons.today),
  title : Text("Birthday"),
  subtitle : Text(contactsModel.chosenDate == null ?
    "" : contactsModel.chosenDate),
  trailing : IconButton(icon : Icon(Icons.edit),
    color : Colors.blue,
    onPressed : () async {
      String chosenDate = await utils.selectDate(
        inContext, contactsModel,
        contactsModel.entityBeingEdited.birthday
      );
      if (chosenDate != null) {
        contactsModel.entityBeingEdited.birthday = chosenDate;
      }
    }
  )
)

That’s all stuff you’ve seen before, aside perhaps from the keyboardType property , which allows us to specify a keyboard tailored to the type of data being input. As you can see, there are several properties available on it like phone and emailAddress, and their meanings I would think are self-explanatory!

Now, we come to the _selectAvatar() method that is called when the user clicks that IconButton next to the avatar Image widget:
Future _selectAvatar(BuildContext inContext) {
return showDialog(context : inContext,
  builder : (BuildContext inDialogContext) {
    return AlertDialog(content : SingleChildScrollView(
      child : ListBody(children : [
        GestureDetector(child : Text("Take a picture"),
          onTap : () async {
            var cameraImage = await ImagePicker.pickImage(
              source : ImageSource.camera
            );
            if (cameraImage != null) {
              cameraImage.copySync(
                join(utils.docsDir.path, "avatar")
              );
              contactsModel.triggerRebuild();
            }
            Navigator.of(inDialogContext).pop();
          }
        )

The job here is to show a dialog where the user selects the source of the avatar image, which can either be from their gallery or the camera. So, we call showDialog()and then return an AlertDialog from its builder function. Inside the AlertDialog we start with a SingleChildScrollView, which is a widget that contains a single widget that can be scrolled. Why use that here? Honestly, there’s no particular reason other than to show you an alternate way to do things. In this case, scrolling doesn’t come into play, but what if you had a few more sources of images you wanted to provide? Rather than ensuring the dialog is big enough to fit them all, you can just allow it to scroll like this.

Anyway, inside the SingleChildScrollView goes a ListBody, which is a widget that arranges its children sequentially along a given axis and forces them to the dimensions of the parent in the other axis. Finally, because we need items that can be clicked, I decided to go with GestureDetector widgets here rather than buttons or something else, though not for any special reason. By doing so, we have an onTap event now that can be applied to this item, which is a Text widget that when clicked will launch the camera. The ImagePicker class is provided by the image_picker plugin which offers functions to access sources of images, the location you want to get the image from being specified by the source property passed to the ImagePicker.pickImage() function. Upon return from that call, if cameraImage isn’t null (it would be null if no picture was taken), then we use the copySync() method, which is available because a File instance is what gets returned to us, to copy it to avatar, which you know now is our temporary avatar image file.

Then, we have to tell the model that it changed, even though in reality it hasn’t! We have to do that because we need Flutter to call our build() method so that the image is shown (remember that code from earlier?). So, the contactsModel.triggerRebuild() method is called, which you’ll remember just calls notifyListeners(), and that causes the image to be shown as a result of the screen being redrawn. Then, we just pop() the dialog away by getting a reference to the BuildContext for the dialog, and we’re good to go.

The other element in the dialog is for selecting an image from the gallery, and it’s the same code, just with a different source specified in the call to pickImage():
GestureDetector(child : Text("Select From Gallery"),
  onTap : () async {
    var galleryImage = await ImagePicker.pickImage(
      source : ImageSource.gallery
    );
    if (galleryImage != null) {
      galleryImage.copySync(
        join(utils.docsDir.path, "avatar")
      );
      contactsModel.triggerRebuild();
    }
    Navigator.of(inDialogContext).pop();
  }
)
Finally, there is the _save() method, but just to wrap this up quickly, I’m just going to show you the small handful of lines that are different from the other _save() methods you’ve examined:
id = await ContactsDBWorker.db.create(
  contactsModel.entityBeingEdited
);
...some other code you’re already familiar with...
File avatarFile = File(join(utils.docsDir.path, "avatar"));
if (avatarFile.existsSync()) {
  avatarFile.renameSync(
    join(utils.docsDir.path, id.toString())
  );
}

The only thing unique to contacts is, of course, the avatar image, and here we have to account for that. If the temporary avatar file is present, then we use the renameSync() function to give it a name matching the id of the contact. The id is captured from the call to the create() method of ContactsDBWorker, which is the only database worker class that does this, and is the ID that was assigned to the contact when it was stored to the database. Of course, when updating an existing contact, we already know that id, so we’re good to go in either case.

And with that, we’ve completed our tour of the first app, FlutterBook!

Summary

In this chapter, we completed our look at the FlutterBook app. You saw how the appointments, contacts, and tasks entities were coded, including things like getting images from the gallery or camera and picking times and dates. With that, we have a complete PIM application that you could use for real if you wanted to, in keeping with the “practical” title of this book!

In the next chapter, we’ll start building the second of our three apps and, in the process, you’ll see some new capabilities of Flutter and even get a taste of some server-side programming and interfacing a Flutter app with it.

Sounds like good, educational fun, no? That’s my goal!

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.134.118.95