Building a Flutter app: re-ordering lists

This post is part of my “Building a Flutter App” dev journal, which begins here.

In this post: I develop a feature that allows a user to manually re-order a shopping list. Taking inspiration from the way Spotify lets users re-order a playlist, Grocery Go’s users should be able to put the items in any order they like.

First, the final product

You can view this step’s pull request here.

This gif demonstrates the feature. The user re-orders items in a list and changes which “store” is active to set different item orders for different stores.

Getting set up – where we’re starting from

When I started work on this feature, the user could already create a shopping list and populate it with items, like so:

However, there is no way to sort or re-order those items.

As a user, I think it’d be useful if the items could be re-ordered to match the order I actually pick them up in when I’m at the store.

But which store? I shop at many different stores, and they’re all laid out differently, so in addition to creating a default order I also want to be able to create variations on the default order and “save” those variations to each store that this list applies to.

Planning the work: UI inspiration

For inspiration, I looked at how Spotify’s phone app (or at least iOS app) gives the user the ability to reorder items in a list.

To change the order of items in this playlist, the user taps the “three dots” adjacent to the “down arrow” above the “add songs” button.

A separate screen pops up and offers the option to “Edit” the playlist:

Tapping “Edit” opens up a modal in which the individual songs can be pressed on and dragged up and down in the list.

In this screenshot, I am dragging the song named “Passage” up in the list. It follows my finger as long as I keep pressing the screen.

Feature list

  • Every shopping list has a “default” order
  • Every shopping list can be linked with 1 or more stores, and each of those store links has its own item order (initially copied from ‘default’)
  • The user can re-order items in the “default” list and the store-specific lists
  • The user can change which store list is being displayed via the shopping list view
  • Creating a brand new item adds it to the end of the “default” list as well as to the end of all store-specific lists

Modeling the data

The user can store different item sequences for different stores, like so:

Default – items are, by default, shown in the order they were created for this list. The user can re-order this list, though. Newly created items are added to the bottom (end) of this list.

  • Party size bag of M&Ms
  • Bread
  • Bananas
  • Milk
  • Eggs

Safeway – the user wants to see the items in this order when they are at Safeway. Newly created items are added at the bottom of this list.

  • Bananas
  • Bread
  • Milk
  • Eggs
  • Party size bag of M&Ms

In the Firebase data structure, I imagined each item would have a map of key/value pairs where the key is the store’s ID and the value is the position in that store’s list.

This worked well and I duplicated this structure for the shopping lists themselves, allowing the shopping lists to be re-ordered on the main screen in addition to the items in each list.

Trouble with “orderBy” and Streams

Initially, I tried to get the items in order (for whatever store list was selected) like this:

Stream<QuerySnapshot> getItemsStream(shoppingListID, isCrossedOff, storeID) {
    return shoppingLists.document(shoppingListID).collection('items').where('isCrossedOff', isEqualTo: isCrossedOff).orderBy('listPositions.$storeID').snapshots();
  }

I thought this was a very clever and the “quite obvious” approach, but the more I tested it, the more apparent it became that using “orderBy” made it so the widget(s) displaying the contents of that stream wouldn’t redraw in the UI, even if coming back from another route. It also broke the ability to cross off items: they would appear in the active and inactive lists at the same time, or they would appear in neither list, until the user reloaded that page of the app.

I went down a lot of different roads trying to fix this, but ultimately all I did was stop using .orderBy and sorted everything on the front-end (in-widget) instead. I don’t know if that’ll end up being a bad idea, but it was the only way I could get both streams and data ordered by some criteria to work together.

Sorting the Stream results

The stream-getting methods in database_manager.dart return a Stream of QuerySnapshots, like so:

Stream<QuerySnapshot> getActiveItemsStream(shoppingListID, storeID) {
    return shoppingLists.document(shoppingListID).collection('items')
        .where('isCrossedOff', isEqualTo: false)
        .snapshots();
  }	  }

And then over here in main_shopping_list.dart, I set a state variable (activeItemsStream) to the return value of that “get stream” call:

void initState() {
    super.initState();
    getSharedPrefs().then((storeIDFromPrefs) {
      activeItemsStream = db.getActiveItemsStream(widget.list.id, storeIDFromPrefs); // should get selectedStoreID from state
      inactiveItemsStream = db.getInactiveItemsStream(widget.list.id, storeIDFromPrefs);
    }); // sets selectedStoreID
  }

To display it, I used a custom widget that I made myself called ItemListStream that takes that state variable as a parameter (called dbStream, first one in the list) and a sortBy parameter.

ItemListStream(dbStream: activeItemsStream, sortBy: selectedStoreID, listType: 'item', onTap: _updateCrossedOffStatus, onInfoTap: _editItem, parentList: widget.list),

That widget file is actually pretty short, here is item_list_stream.dart in its entirety. Notice the sort performed on the list items, this takes the place of “orderBy” on the database call.

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:grocery_go/components/item_list.dart';
import 'package:grocery_go/models/shopping_list.dart';

class ItemListStream extends StatelessWidget {

  final dbStream;
  final sortBy;
  final listType; // item, crossedOff
  final onTap;
  final onInfoTap;
  final ShoppingList parentList;

  ItemListStream({@required this.dbStream, @required this.sortBy, @required this.listType, @required this.onTap, @required this.onInfoTap, this.parentList});

  @override
  Widget build(BuildContext context) {
    return StreamBuilder(
      stream: dbStream,
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          return Text('Error: ${snapshot.error}');
        }
        if (snapshot.hasData && !snapshot.data.documents.isEmpty) {
          List<DocumentSnapshot> docs = snapshot.data.documents;

          if (listType != 'crossedOff') {
            docs.sort((a, b) {
              return a.data['listPositions'][sortBy].compareTo(b.data['listPositions'][sortBy]);
            });
          } else {
            docs.sort((b, a) {
              return a.data['lastUpdated'].toDate().compareTo(b.data['lastUpdated'].toDate());
            });
          }

          return ItemList(list: docs, listType: listType, onItemTap: onTap, onInfoTap: onInfoTap, parentList: parentList);
        } else {
          return Column(
              children: [
                Padding(
                  padding: EdgeInsets.all(8),
                  child: Text("No items yet!"),
                ),
              ],
          );
        }
      }
    );
  }
}

Building the “reorder” UI

I tap the “three dots in a row” in the blue “Items” bar, then I drag “Bag of potatoes” up one spot. The change is reflected on the previous screen and in Firebase.

Check out reorderable_list.dart to see how this works.

I adapted the reorder logic from this very helpful example: https://gist.github.com/slightfoot/bfaaf6338d85e27b2acfe1b265ee5f27

Building the “store change” UI

Finally, the user needed a way to change which store was selected. Here’s what I built:

I built this using a Cupertino Action Sheet. Look in main_shopping_list.dart for the full code. Changing the selected store calls _setSelectedStore, which updates the selectedStoreID state variable and “re-gets” the active items and inactive items streams with that updated ID.

_setSelectedStore(String id, String name) async {
    final SharedPreferences prefs = await SharedPreferences.getInstance();
    prefs.setString(widget.list.id, id);

    setState(() {
      selectedStoreID = id;
      selectedStoreName = _getStoreName(id);
      activeItemsStream = db.getActiveItemsStream(widget.list.id, id);
      inactiveItemsStream = db.getInactiveItemsStream(widget.list.id, id);
    });

    Navigator.pop(context, id);
  }

The final product

And that’s it for this feature! Here’s the feature’s pull request.

Here’s what we did:

  • Added a new view that lets the user change the order of shopping list items by dragging and dropping them individually
  • Added the ability to change which store is active for a shopping list
  • Added “listPositions” map to each item so each item knows where it appears in each store

Go back to Part 7’s feature work list.

Building a Flutter app: creating a many-to-many relationship between stores and shopping lists

This post is part of my “Building a Flutter App” dev journal, which begins here.

In this post: I develop a feature that allows the user to “link” stores and shopping lists to each other. This link is managed on the “edit store” and “edit shopping list” pages. A link is bidirectional: adding a store to a shopping list also adds that shopping list to the store.

First, the final product

The user can toggle every one of their stores on/off for each shopping list.

Feature description and purpose

This feature has a few purposes:

  • allow items from multiple shopping lists to appear in one store (so when you’re at, for example, “Fred Meyer”, you can see items for “groceries” and “home improvement”)
  • allow the user to save different item orders for different stores (this work was documented in this article)
  • make it easier for the user to know which store they need to go sooner to based on which items they need

Deciding how to represent the store/list links in the database

First, I consulted Firebase’s docs on structuring data to see what they had to say about representing this kind of many-to-many relationship. Their “users and groups” example is very close to what I want to build here.

They recommend using an index of groups, like so:

// An index to track Ada's memberships
{
  "users": {
    "alovelace": {
      "name": "Ada Lovelace",
      // Index Ada's groups in her profile
      "groups": {
         // the value here doesn't matter, just that the key exists
         "techpioneers": true,
         "womentechmakers": true
      }
    },
    ...
  },
  "groups": {
    "techpioneers": {
      "name": "Historical Tech Pioneers",
      "members": {
        "alovelace": true,
        "ghopper": true,
        "eclarke": true
      }
    },
    ...
  }
}

(This was taken directly from the Firebase docs)

Next, I adapted their example to match my project. This is just mock data in a text file, it’s not in the project’s codebase anywhere.

// An index to track a shopping list's stores
{
  "shopping_lists": {
    "list123": {
      "name": "Groceries",
      "stores": {
         "store890": true,
         "store567": true
      }
    },
    ...
  },
  "stores": {
    "store890": {
      "name": "Safeway, Kirkland",
      "shopping_lists": {
        "list123": true,
        "list124": true
      }
    },
    ...
  }
}

Now I have a clear goal to work towards, but this should be straightforward to implement. I will have to update/maintain the link data in two places, so my Database Manager functions will account for that.

Inserting the data into the database by hand

My data differs from Firebase’s sample data in that I use the auto-generated IDs to identify my records, but I don’t show the user those IDs, I show them the name instead. I decided to change my mock data to look like this, instead:

  "shopping_lists": {
    "list123": {
      "name": "Groceries",
      "stores": {
         "store890": "Safeway, Kirkland",
         "store567": "Fred Meyer, Kirkland"
      }
    },
    ...
  },

Now the store ID are the key and store name is the value.

The next step was to put this data into my Firebase document by hand. Here is my “Back to school stuff” shopping list with two stores associated with it.

Now I’ll have something to display in the UI (and later edit).

Building the store/list linking UI

The first page I worked on was the “Edit shopping list” page. I considered doing a list of toggle switches under the “List name” field, but that list could potentially be very long and I didn’t want to push the “Save” button off screen.

Instead, I decided to show a short (truncated) list of linked stores on this page and provide a link that opens up a new view full of toggle switches that represent each possible store link.

Note: I knew that whatever UI I built for this feature would also be used by the “Edit Store” page, so I built everything using (reusable) components and named them generically.

shopping_list_form.dart now contains an internally-used method titled _formFields. This method builds a List of widgets and includes _nameField() in it by default. Whether you’re editing or creating a new shopping list, you’ll always get _nameField.

If the shopping list id is not null, then we can also show the “linked entities”.

 _formFields() {
    List<Widget> fields = [_nameField()];
    // if we're editing an existing shopping list, add the linked stores
    if (widget.shoppingList?.id != null) {
      fields.add(_linkedEntities());
    }

    return Container(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: fields,
      ),
    );
  }

_linkedEntities is a separate method that returns another widget, LinkedEntitiesList.

  _linkedEntities() {
    return LinkedEntitiesList(
        widget.shoppingList.id, "shopping list", widget.shoppingList.name, widget.shoppingList.stores, "Stores");
  }

linked_entities_list.dart is shown here in its entirety from the final version of the feature branch. The thing that’s interesting here is the use of the spread operator to build an “entity list” out of some unknown number of list elements. This turned out to be a good technique for adding a variable number of children to a column’s children array.

import 'package:flutter/material.dart';
import 'package:grocery_go/views/manage_links.dart';
import '../../db/database_manager.dart';

class LinkedEntitiesList extends StatelessWidget {
  final String parentID;
  final String parentName;
  final String listType;
  final Map linkedEntities;
  final String entities;

  LinkedEntitiesList(this.parentID, this.listType, this.parentName, this.linkedEntities, this.entities);

  final DatabaseManager db = DatabaseManager();

  @override
  Widget build(BuildContext context) {

    _goToManageLinks() {
      var stream;
      if (listType == "shopping list") {
        stream = db.getStoresStream();
      } else if (listType == "store") {
        stream = db.getShoppingListStream();
      } else {
        print("Error: unrecognized list type in linked_entities_list.dart");
      }

      Navigator.pushNamed(context, ManageLinks.routeName, arguments: ManageLinksArguments(dbStream: stream, linkedEntities: linkedEntities, parentID: parentID, parentName: parentName, parentType: listType));
    }

    var _list = linkedEntities != null ? linkedEntities.values.toList() : [];

    return Container(
      height:300,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          _listTitle(),
          ..._entityList(_list),
          _manageLinksButton(_goToManageLinks),
        ],
      ));
    }

  _listTitle() {
    return Text("$entities", style: TextStyle(fontSize:16, fontWeight: FontWeight.bold));
  }

  _entityList(list) {
    const MAX_LIST_LEN = 4;

    if (list == null || list?.length == 0) {
      return [Text("This $listType is not attached to any $entities yet.")];
    } else {
      var listLen = list.length > MAX_LIST_LEN ? MAX_LIST_LEN : list.length;
      var entityList = List();
      for (var i = 0; i < listLen; i++) {
        entityList.add(Text(list[i].toString()));
      }
      if (list.length > MAX_LIST_LEN) {
        entityList.add(Text('+${list.length - MAX_LIST_LEN} more'));
      }
      return entityList;
    }
  }

  _manageLinksButton(onPressedAction) {

    return FlatButton(
      onPressed: () => onPressedAction(),
      child: Text("Add/Remove $entities"),
      textColor:Colors.blue,
      padding: EdgeInsets.all(0),
    );
  }
}

Notes:

  • I called them “entities” here because these widgets work for stores or shopping lists
  • I had to make a similar set of changes to the store_form.dart component
  • Getting a Column widget to have different children based on some condition was a new challenge. The best solution I could come up with was to use a List, called “fields” in this example, to hold the widgets that should be the column’s children. If a condition is met (in my case, shopping list id is not null), then a widget is pushed to the fields List (otherwise, it is not pushed). That approach seemed to be a good way to make the Columns children vary as needed.
  • user2875289‘s answer in this Stack Overflow question (method 2 and method 3 to be exact) helped me figure out how to iterate through a list to create Text widgets and how to include another Text widget child in that same array of children.

Firebase updates: adding a new store (or shopping list) to the map

Every Shopping List is going to have a map of Stores, like this:

"shoppingListID01" : {
  "name": "Groceries",
  "stores" : {
    "storeID01": "Safeway",
    "storeID02": "Fred Meyer"
  }
}

And when a new store is added, it has to be added without affecting the rest of the. map:

"stores" : {
  "storeID01": "Safeway",
  "storeID02": "Fred Meyer",
  "storeID03": "Home Depot" // NEW ONE, didn't mess up Safeway or Fred Meyer
}

And vice versa for Stores – every Store record keeps a map of its linked Shopping Lists.

"storeID01" : {
  "name": "Safeway",
  "stores" : {
    "shoppingListID01": "Groceries",
    "shoppingListID02": "Pool party stuff"
  }
}

Every “link” is created in two places:

  • Adding a store to a shopping list also adds that shopping list to that store
  • Adding a shopping list to a store also adds that store to that shopping list

For the sake of “Step 1” here I am just going to work on adding a new store link to the map of stores.

My initial (dead end) approach with “update” and “merge: true” and why that didn’t work with Cloud Firestore

From reading the Firebase docs I knew I was going to need to use update and {merge: true} because I want to keep the rest of the object untouched.

This was my first attempt, but it doesn’t work because apparently “update” is not usable if you’re using the cloud firestore package.

Future updateShoppingListLink(String parentStoreID, String entityID, bool val) async {

    DocumentReference storeRef =  stores.document(parentStoreID);

    Map<String, Map> data = {
      "shoppingLists": {
        entityID: val
      },
    };

    storeRef.update(data, {merge: true}); // doesn't work, "update" doesn't exist 
  }
}

I looked more closely at the cloud firestore docs, and they show “updateData” instead of “update”, so I swapped update for updateData:

storeRef.updateData(data, {merge: true}); // doesn't work, doesn't accept "merge"

updateData doesn’t accept the “merge” object, though. It says “too many positional arguments”.

Just to see what would happen, I took off the merge object and updated my database entry without the merge flag present.

storeRef.updateData(data);

The good news was – this “worked” in the sense that the new shopping list ID was successfully added to the store document:

… but as soon as you add a second store, that first store was overwritten and replaced with the new one:

At this point, I figured I was just not correctly passing “merge true” to updateData. I found this seemingly-related thread from 2017 that referenced this merged code that suggested SetOptions.merge() could be applied.

Here’s their example (I took this from their test, I couldn’t find it in their docs):

test('merge set', () async {
        await collectionReference
            .document('bar')
            .setData(<String, String>{'bazKey': 'quxValue'}, SetOptions.merge);
        expect(SetOptions.merge, isNotNull);

Which I adapted to my code:

storeRef.updateData(data, SetOptions.merge); // doesn't work, expects 1 parameter not 2

Making it all one object didn’t work, either:

storeRef.updateData({data, SetOptions.merge}); // also does not work

Then I found this downvoted Stack Overflow reply suggesting this syntax, which also does not work (it wants 1 argument, not 2):

storeRef.updateData(data, SetOptions(merge: true)); // also does not work

I kept digging and found this thread on the issue, which was last updated less than two months ago and says that merge and mergeFields are coming to the FlutterFire plugin set (which includes the cloud firestore plugin) in this update. Specifically, here are the “patch notes” for the upcoming changes to cloud firestore, which are still in review as of this writing (July 2020).

Ahh, so it seems there’s a big update coming soon that will fix this, but “merge true” is a lost cause at this point in time. If you’re reading this in the future, perhaps you have the updated cloud firestore package and none of this is a problem, but for those of us using it as it is now, I thought I’d see if I could find a workaround.

I started to wonder if I could just access shoppingLists with dot notation and gave this a try:

// working example of how to update one field in an existing map without deleting the others
  
Future updateShoppingListLink(String parentStoreID, String entityID, bool val) async {
    DocumentReference storeRef =  stores.document(parentStoreID);
    storeRef.updateData({'shoppingLists.$entityID': val});
  }

Yay, it worked!

This technique makes it possible to add a new field to the map without deleting the existing ones in the process.

Perhaps I didn’t need “merge true” in the first place, but I’ll leave my notes up in case they’re helpful to anyone else trying to update one entry in a map in a Firebase document.

TL;DR: “dot notation” was a good way to update specific field in a map contained within a Firebase document.

storeRef.updateData({'shoppingLists.$entityID': val});

Passing the store name as the value

Currently, the “val” passed to the shoppingList map is a boolean value but what I really need is the store’s name.

I changed the database_manager.dart method to take a String called name instead:

  Future updateShoppingListLink(String parentStoreID, String entityID, String name) async {
    DocumentReference storeRef =  stores.document(parentStoreID);
    storeRef.updateData({'shoppingLists.$entityID': name});
  }

And then I changed the toggleItem method in toggle_list.dart to pass the name instead of the value:

class _ToggleListState extends State<ToggleList> {

  final DatabaseManager db = DatabaseManager();

  toggleItem(entityID, entityName) {
    print(widget.parentType);
    if (widget.parentType == "shopping list") {
      db.updateStoreLink(widget.parentID, entityID, entityName);
    } else if (widget.parentType == "store") {
      db.updateShoppingListLink(widget.parentID, entityID, entityName);
    }
  }

  @override
  Widget build(BuildContext context) {

    return ListView.builder(
        shrinkWrap: true, // gives it a size
        itemCount: widget.list.length,
        itemBuilder: (BuildContext context, int index) {
          var item = LinkedEntity(widget.list[index]);

          return SwitchListTile(
            title: Text(item.name),
            value: widget.linkedEntities?.containsKey(item.id) ?? false,
            onChanged: (bool value) => toggleItem(item.id, item.name),
          );
        }
    );
  }
}

This works – hooray! – but it reveals a new problem: what happens when the user changes the shopping list’s name?

Thoughts on what happens when a shoppingList (or Store) gets renamed:

Firebase doesn’t seem to shy away from redundant data, and the alternative seems to be to store the IDs alone and then perform a “what name goes with this ID?” look up every time the user views or manages the linked lists (or stores).

In my app, it’s probably way more common to view/manage the links than it is to rename a shopping list or store, and I don’t anticipate users having more than about 5-10 of each, so I am going to (cautiously) proceed with the idea that it’s better to update the name in multiple places if the name changes vs. the idea that the name should be looked up every time the user views a list.

I’ll revisit what happens when a shopping list or store is renamed later on in this article.

Removing an existing link from the map

Everything I’ve done so far is for creating a link.

The user can also remove a link (by toggling it to “false” in the list), which I imagined would have a database equivalent of removing the item from the map entirely. What I don’t want to do is make a copy of the entire linkings map, remove the single entry that’s going away, and then push the entire updated map.

As usual, I began with a bit of research and found that the “dot notation” that served me so well for adding a field to a map can also be used with FieldValue.delete().

Here, I’ve written a ternary that looks at the value of val. If true, it updates the ‘stores’ or ‘shoppingLists’ map with the entity’s ID and name. If false, it removes the given entity ID from ‘stores’ or ‘shoppingLists’.

  Future updateStoreLink(String parentListID, String entityID, String name, bool val) async {
    DocumentReference shoppingListRef = shoppingLists.document(parentListID);
    val == true ? shoppingListRef.updateData({'stores.$entityID': name}) : shoppingListRef.updateData({'stores.$entityID': FieldValue.delete()});
  }

  Future updateShoppingListLink(String parentStoreID, String entityID, String name, bool val) async {
    DocumentReference storeRef =  stores.document(parentStoreID);
    val == true ? storeRef.updateData({'shoppingLists.$entityID': name}) : storeRef.updateData({'shoppingLists.$entityID': FieldValue.delete()});
  }

In toggle_list.dart I had to make a few changes to the toggleItem method. If widget.linkedEntities is null, it creates an empty new Map(). Without this, a store (or shopping list) that doesn’t have anything in its linked shoppingLists (or stores) map will be interpreted as ‘null’, causing .containsKey to throw an exception.

Here is toggle_list.dart in its entirety.

import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:grocery_go/db/database_manager.dart';

class LinkedEntity {
  String id;
  String name;

  LinkedEntity(DocumentSnapshot document) {
    this.id = document['id'];
    this.name = document['name'];
  }
}

class ToggleList extends StatefulWidget {

  final String parentType;
  final String parentID;
  final List list;
  Map linkedEntities;

  ToggleList({Key key, @required this.parentType, @required this.parentID, @required this.list, @required this.linkedEntities});

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

class _ToggleListState extends State<ToggleList> {

  final DatabaseManager db = DatabaseManager();

  toggleItem(entityID, entityName, value) {

    if (widget.linkedEntities == null) {
        widget.linkedEntities = Map();
    }

    // update "locally"
    if (widget.linkedEntities.containsKey(entityID)) {
      setState(() {
        widget.linkedEntities.remove(entityID);
      });
    } else {
      setState(() {
        widget.linkedEntities[entityID] = entityName;
      });
    }
    // update in database
    if (widget.parentType == "shopping list") {
      db.updateStoreLink(widget.parentID, entityID, entityName, value);
    } else if (widget.parentType == "store") {
      db.updateShoppingListLink(widget.parentID, entityID, entityName, value);
    }
  }

  @override
  Widget build(BuildContext context) {

    return ListView.builder(
        shrinkWrap: true, // gives it a size
        itemCount: widget.list.length,
        itemBuilder: (BuildContext context, int index) {
          var item = LinkedEntity(widget.list[index]);

          return SwitchListTile(
            title: Text(item.name),
            value: widget.linkedEntities?.containsKey(item.id) ?? false,
            onChanged: (bool value) => toggleItem(item.id, item.name, value),
          );
        }
    );
  }
}

Here’s where we’re at now:

  • Stores and Shopping Lists have a map of their “linked entities” (stores keep track of their linked shopping lists, shopping lists keep track of their linked stores)
  • Linked entities can be linked/unlinked using a toggle switch
  • The data is changed in the database as the user toggles a link on/off

Making the link a two-way link

If the user adds the “swim stuff” list to the “Toys R Us” store, then “swim stuff” list should also get an association with the “Toys R Us” store. In other words, adding or removing a store from a shopping list should also add or remove that same shopping list from that same store.

Each existing method basically had to repeat the code of the other – once I wrote this out, I realized I could combine them into one method.

Future updateStoreLink(String shoppingListID, String storeID, String name, bool val) async {
    // add a store to the specified shopping list
    DocumentReference shoppingListRef = shoppingLists.document(shoppingListID);
    val == true ? shoppingListRef.updateData({'stores.$storeID': name}) : shoppingListRef.updateData({'stores.$storeID': FieldValue.delete()});

    // do the opposite - add this shopping list to the specified store
    DocumentReference storeRef =  stores.document(storeID);
    val == true ? storeRef.updateData({'shoppingLists.$shoppingListID': 'temp'}) : storeRef.updateData({'shoppingLists.$shoppingListID': FieldValue.delete()});
  }

  Future updateShoppingListLink(String storeID, String shoppingListID, String name, bool val) async {
    // add a shopping list to the specified store
    DocumentReference storeRef =  stores.document(storeID);
    val == true ? storeRef.updateData({'shoppingLists.$shoppingListID': name}) : storeRef.updateData({'shoppingLists.$shoppingListID': FieldValue.delete()});

    // do the opposite - add this store to the specified shopping list
    DocumentReference shoppingListRef = shoppingLists.document(shoppingListID);
    val == true ? shoppingListRef.updateData({'stores.$storeID': 'temp'}) : shoppingListRef.updateData({'stores.$storeID': FieldValue.delete()});
  }

Here they are refactored into one method, with a change made to the signature to take both the shopping list name and store name.

Future updateStoreShoppingListLink(String shoppingListID, String storeID, String shoppingListName, String storeName, bool val) async {
  // add this store to the specified shopping list
  DocumentReference shoppingListRef = shoppingLists.document(shoppingListID);
  val == true ? shoppingListRef.updateData({'stores.$storeID': storeName}) : shoppingListRef.updateData({'stores.$storeID': FieldValue.delete()});

  // and add this shopping list to the specified store
  DocumentReference storeRef =  stores.document(storeID);
  val == true ? storeRef.updateData({'shoppingLists.$shoppingListID': shoppingListName}) : storeRef.updateData({'shoppingLists.$shoppingListID': FieldValue.delete()});
}

Back in toggle_list.dart, I still have to call db.updateStoreShoppingListLink(...); in two different places because the concept of ‘parentID’ and ‘entityID’ are variable based on whether the user came in from the “edit store” flow or the “edit shopping list” flow.

When the user came in from “edit shopping list”, the parentID is a shoppingList’s ID. When the user comes in from “edit store”, the parentID is a store’s ID.

The updateStoreShoppingListLink method always expects the params in this order:

method params: (shoppingListID, storeID, shoppingListName, storeName, value)

… so we change the order of widget.parentID and entityID as dictated by the list type.

// update in database
// method params: (shoppingListID, storeID, shoppingListName, storeName, value)

if (widget.parentType == "shopping list") {
  // if we're editing a shopping list then the parent ID is the list ID and the entity is the store
  db.updateStoreShoppingListLink(widget.parentID, entityID, widget.parentName, entityName, value);
} else if (widget.parentType == "store") {
  // if we're editing a store, then the parent ID is the store ID and the entity is the shopping list
  db.updateStoreShoppingListLink(entityID, widget.parentID, entityName, widget.parentName, value);
}

In this demo, toggling the “Swim stuff” list on for the “Toys R Us” store also adds the “Toys R Us” store to the “swim stuff” list.

All of these use cases work now:

  • Add a shopping list to a store adds that same store to that shopping list
  • Remove a shopping list from a store removes that same store from the shopping list
  • Add a store to a shopping list adds that same shopping list to that store
  • Remove a store from a shopping list removes that same shopping list from the store
  • User can remove all the shopping lists from a store
  • User can remove all the stores from a shopping list
  • Create a new store and add/remove shopping lists to it
  • Create a new shopping list and/remove stores to it

Handling long lists of linked entities

It’s possible that a user will add lots of stores to a shopping list (or lots of shopping lists to a store), so the list has to truncate after a to-be-determined number of items.

Already, we’re seeing some overflow after just three linked entities are present:

Currently, the logic that creates this list looks like so:

  _entityList(list) {
    var shortList = List();
    shortList.add(Text("This $listType is not attached to any $entities yet."));
    // if 'list' is empty, default to shortList which is guaranteed to have something
    return list?.map((item) => Text(item.toString(), style: TextStyle(height: 1.6)))?.toList() ?? shortList;
  }

The refactor needs to do the following:

  • display up to N (probably 4 or 5) items
  • append a Text widget showing count of how many items remain, ie: “+ 2 more”
  • still return a Text widget that says “This listType is not attached to any $entities yet” when the list is empty

Here’s what I ended up with. (There are probably more succinct ways to write this, but hopefully it’s clear what it’s doing.)

  _entityList(list) {
    const MAX_LIST_LEN = 4;

    if (list == null || list?.length == 0) {
      return [Text("This $listType is not attached to any $entities yet.")];
    } else {
      var listLen = list.length > MAX_LIST_LEN ? MAX_LIST_LEN : list.length;
      var entityList = List();
      for (var i = 0; i < listLen; i++) {
        entityList.add(Text(list[i].toString()));
      }
      if (list.length > MAX_LIST_LEN) {
        entityList.add(Text('+${list.length - MAX_LIST_LEN} more'));
      }
      return entityList;
    }
  }

The result:

Adding “store address” anywhere store names are displayed (store list, toggle list)

When I built the ToggleList and the linked entities list, I overlooked the (common) use case of the user having multiple stores with the same name. Without each store’s address on display, it’s hard to tell identically named stores apart.

However, I have a bit of a conundrum: “entities” (as they are), are just persisted to the database as a string representing the store (or shopping list’s) name. There’s no address field, nor do I really want to add one at this point.

I decided the simplest course of action would be appending the location information to the name right before it’s pushed into the database, like so: “Safeway (Kirkland)” and see how far that carries me. Is this hacky? Maybe ;) But it feels good enough for now.

In toggle_list.dart, the full list of shopping lists or stores is passed in as a Map, known as this.list:

ToggleList({Key key, @required this.parentType, @required this.parentID, @required this.parentName, @required this.list, @required this.linkedEntities});

Around line 72, each of these list items (which can be stores or shopping lists) are turned into LinkedEntity instances:

var item = LinkedEntity(widget.list[index]);

Both stores and shopping lists become instances of LinkedEntity, and they can share this “base class” because the only things used are their id and name. But now they have a third field: address. If there is an address it’s saved to the address field, but if there is no address (ie: shopping lists), it’ll just set address to be empty.

class LinkedEntity {
  String id;
  String name;
  String address;

  LinkedEntity(DocumentSnapshot document) {
    this.id = document['id'];
    this.name = document['name'];
    this.address = document['address'] ?? '';
  }
}

Then, when the list is built, the itemName is assembled out of either item.name + item.address, or just item.name if there was no address.

@override
  Widget build(BuildContext context) {

    return ListView.builder(
        shrinkWrap: true, // gives it a size
        itemCount: widget.list.length,
        itemBuilder: (BuildContext context, int index) {
          var item = LinkedEntity(widget.list[index]);
          var itemName = item.address.length > 0 ? item.name + ' (${item.address})' : item.name;
          return SwitchListTile(
            title: Text(itemName),
            value: widget.linkedEntities?.containsKey(item.id) ?? false,
            onChanged: (bool value) => toggleItem(item.id, item.name, value),
          );
        }
    );
  }

Now it’s much easier to tell same-name stores apart in the toggle list:

The same address treatment would be useful on the form page, too:

This page is trickier, because this list is taken from the saved “stores” data in the database. I considered a few different solutions, all of which felt cumbersome (on top of some already-cumbersome-feeling logic), until it dawned on me that I could just persist the “StoreName (Address)” string to the database.

It ended up being a one-line (one word, really) change in toggle_list.dart:

          return SwitchListTile(
            title: Text(itemName),
            value: widget.linkedEntities?.containsKey(item.id) ?? false,
            onChanged: (bool value) => toggleItem(item.id, itemName, value),

Now when toggleItem is called, the same “StoreName (Address)” string is passed to the database.

I’ll go with this approach for now, which seems “good enough” for the sake of this project. (I toggled each store on/off to update it to the new StoreName (Address) format.)

Two more changes…

Before moving on, I had to make two more (small) changes to the code that runs when an existing shopping list or store is updated (renamed) or when a new shopping list or store is created.

store_form.dart

void updateStore(BuildContext context) async {
    final formState = formKey.currentState;

    if (formState.validate()) {
      formKey.currentState.save();
      storeFields.date = DateTime.now().toString();

      if (widget.store != null) {
        storeFields.id = widget.store.id;
        // 1
        storeFields.shoppingLists = widget.store.shoppingLists;
        await db.updateStore(widget.store.id, storeFields);
      } else {
        // 2
        storeFields.shoppingLists = Map();
        await db.addStore(storeFields);
      }

      Navigator.of(context).pop();
    }
  }
  1. If the store already exists (it’s not null), then copy its shoppingLists into storeFields.shoppingLists and send them along to db.updateStore(...).
  2. If the store is null, then it’s a new one being created, so create a new Map() for storeFields.shoppingLists and send that along to the db. Without this, a new shopping list has “null” for its shoppingLists map and the code that adds a new shopping list ID to it fails.

I made a similar set of changes to shopping_list_form.dart.

Updating renaming shopping lists and renaming stores to also update any saved links

The last major piece of work on this feature is making it so that updating a shopping list’s name (or a store’s name) is properly propagated to all of the documents that have it stored.

As far as I can tell, having redundancies like this (such as storing a store’s name or shopping list in multiple places) is oftentimes the preferred way of storing records in Firebase.

Per my own logic, it’s fairly uncommon to change the name of a list or a store but very common to view a store or a list in multiple places. It seemed better to take on the burden of having to update multiple records with a name change once in a rare while vs. the burden of looking up the name for every store and shopping list, by ID, every time the user viewed a list of them.

This piece of work needs to achieve the following:

  • When the user changes the name of a Store, step through each existing Shopping List and look for that store.id in each Shopping List’s “stores” map. If any match is found, update the name saved for that store entry.
  • When the user changes the name of a Shopping List, step through each existing Store and look for that shoppingList.id in each Store’s “shoppingList” map. If any match is found, update the name saved for that shopping list entry.

I started my work with renaming stores first, because stores are more complicated. Stores record their name separate from their location (also called “address” in the code), but their name and location are concatenated together when saving them into a shopping list’s list of stores.

In database_manager.dart, I confirmed that the name and address are accessible on the store DTO with a couple of print statements:

  Future updateStore(String id, StoreDTO store) async {
    print(store.name);
    print(store.address);
    if (id != null && id.length > 0) {
      DocumentReference docRef = stores.document(id);
      Firestore.instance.runTransaction((transaction) async {
        await transaction.update(docRef, store.toJson());
      }).catchError((e) {
        print(e.toString());
      });
    } else {
      print("ID is null/has no length");
    }
  }

Then passed them along to a new method called updateLinkedShoppingLists:

  Future updateStore(String id, StoreDTO store) async {
    if (id != null && id.length > 0) {
      DocumentReference docRef = stores.document(id);
      Firestore.instance.runTransaction((transaction) async {
        await transaction.update(docRef, store.toJson());
      }).catchError((e) {
        print(e.toString());
      });
      updateLinkedShoppingLists(store.id, store.name + " (" + store.address + ")");
    } else {
      print("ID is null/has no length");
    }
  }

    Future updateLinkedShoppingLists(storeID, newName) async {
    // update all the shopping lists's "stores" maps to use the new store name
    await shoppingLists
        .getDocuments()
        .then((querySnapshot) => {
          querySnapshot.documents.forEach((doc) => {
            if (doc.data['stores'][storeID] != null) { // can't use ['stores.$storeID']
              doc.reference.updateData({'stores.$storeID': newName})
            }
          })
        });
  }

updateLinkedShoppingLists gets all the shopping list documents from the shoppingLists collection then iterates through them. On each one, it checks if doc.data['stores'][storeID] is not null, and if it’s not null, it updates the stores entry to have the new name.

The hardest part of this process was figuring out how to only updates the stores {"storeID": "storeName"} entry for shopping lists that actually had this store in their store map. Without this logic check, every shopping list gets the store added, whether it had it before or not, but figuring out how to limit the updateData call to just the documents that had that particular store ID in its store map was a challenge. The docs didn’t really cover this scenario, and the stores.$storeID syntax didn’t work.

In other words, I couldn’t do this:

if (doc.data['stores.$storeID'] != null) { // doesn't work

It seems the handy-dandy store.$storeID lookup is only for use in the updateData({...}) call. For checking if a Firebase document had a particular entry in a map, this was the syntax that worked:

if (doc.data['stores'][storeID] != null) { ... 

Perhaps because it doesn’t know the structure of ‘stores’ so it can’t use the dot notation. Either way, here is the “rename everywhere” feature working for Stores:

And getting it working for renaming shopping lists was as easy as writing the same thing again, but for shopping lists and their linked stores:

  Future updateShoppingList(String id, ShoppingListDTO shoppingList) async {
    if (id != null && id.length > 0) {
      DocumentReference docRef = shoppingLists.document(id);
      Firestore.instance.runTransaction((transaction) async {
        await transaction.update(docRef, shoppingList.toJson());
      }).catchError((e) {
        print(e.toString());
      });
      updateLinkedStores(shoppingList.id, shoppingList.name); // call new method
    } else {
      print("ID is null/has no length");
    }
  }

  Future updateLinkedStores(shoppingListID, newName) async {
    // update all the stores' "shopping lists" maps to use the new shopping list name
    await stores
        .getDocuments()
        .then((querySnapshot) => {
      querySnapshot.documents.forEach((doc) => {
        if (doc.data['shoppingLists'][shoppingListID] != null) {
          doc.reference.updateData({'shoppingLists.$shoppingListID': newName})
        }
      })
    });
  }

And that’s it! Here’s the feature’s pull request.

Here’s what we did:

  • Added a new view that lets the user toggle stores “on/off” for shopping lists (and shopping lists “on/off” for stores)
  • Wrote new Database Manager methods to create/delete a two-way link between stores and shopping lists whenever one is added or removed
  • Added a map to the store documents (and a map to the shopping list documents) that tracks the IDs and names of any linked entities
  • Made it so that changing the name of a store or shopping list also updates its name in any documents that have it in their linked entities map
  • Updated the store form and shopping list forms to display a list of linked entities, with special handling for cases where there are more than 4 linked entities

Go back to Part 7’s feature work list.

Building a Flutter app, part 7: in which I blast through a ton of feature work

The setup and database hookups are done, so now it’s time to just pump out features.

Each feature has its own article:

  1. Crossing off shopping list items
  2. Dark UI mode
  3. Linking stores and lists
  4. Reordering items on a per-store basis
  5. Mark an item as exclusive to a store(s) [Coming soon]
  6. Substitutes list – [Coming soon]
  7. Add a picture to an item – [Coming soon]
  8. Enhanced “store location” – [Coming soon]
  9. Deleting items, shopping lists, and stores – [Coming soon]

Coming soon: Part 8, user accounts.

Building a Flutter app: “Cross off” feature

This post is part of my “Building a Flutter App” dev journal, which begins here.

In this post: I built the “cross off” feature to my “Grocery Go” shopping list management app.

First, the final product

Watch as items move between the top list (the “active” list) and the bottom list (the “crossed off” list).

Feature list

Here are all the things that should happen when this feature is considered done:

  • When the user taps the item, it is visibly removed from the shopping list and added to the “Crossed off” list below the shopping list
  • The parent shopping list itemCount is decreased by one
  • A “crossed off” item appears at the top of the crossed off items list
  • The time/date of this action is saved to the item so we know when it was crossed off [dateLastMoved]
  • If the user taps a “crossed off” item, it returns to the shopping list and the itemCount is increased by one
  • The time/date of this action is saved to the item so we know when it was added to the list [dateLastMoved]
  • Shopping Lists maintain their own lists of Crossed Off items, so that the user doesn’t see a crossed off item from their “home improvement” list in their “groceries” list – add CrossedOff subcollection to Shopping List

Adding the isCrossedOff property to Item

I thought it might be simple to just have each item track whether it is “active” or “crossed off”. isCrossedOff will be a boolean value on Item instances, so it had to be added to any code that models Item’s data.

That includes item_dto.dart:

class ItemDTO {

  String id;
  String name;
  String date;
  int quantity;
  bool subsOk;
  List substitutions;
  String addedBy;
  String lastUpdated;
  bool private;
  bool urgent;
  bool isCrossedOff;

  String toString() {
    return 'id: $id, name: $name, date: $date, '
        'quantity: $quantity, subsOk: $subsOk, substitutions: $substitutions, '
        'addedBy: $addedBy, lastUpdated: $lastUpdated, private: $private, urgent: $urgent, isCrossedOff: $isCrossedOff';
  }

  Map<String, dynamic> toJson() => <String, dynamic> {
    'id': this.id,
    'name': this.name,
    'date': this.date,
    'quantity': this.quantity,
    'subsOk': this.subsOk,
    'substitutions': this.substitutions,
    'addedBy': this.addedBy,
    'lastUpdated': this.lastUpdated,
    'private': this.private,
    'urgent':this.urgent,
    'isCrossedOff':this.isCrossedOff,
  };
}

models/item.dart:

import 'package:cloud_firestore/cloud_firestore.dart';

class Item {
  String id;
  String name;
  String date;
  int quantity;
  bool subsOk;
  List substitutions;
  String addedBy;
  String lastUpdated;
  bool private;
  bool urgent;
  bool isCrossedOff;

  Item(DocumentSnapshot document) {
    this.id = document['id'];
    this.name = document['name'];
    this.date = document['date'];
    this.quantity = document['quantity'];
    this.subsOk = document['subsOk'];
    this.substitutions = document['substitutions'];
    this.addedBy = document['addedBy'];
    this.lastUpdated = document['lastUpdated'];
    this.private = document['private'];
    this.urgent = document['urgent'];
    this.isCrossedOff = document['isCrossedOff'];
  }
}

new_item_form.dart

    if (formState.validate()) {
      formKey.currentState.save();

      itemFields.date = DateTime.now().toString();
      itemFields.lastUpdated = DateTime.now().toString();
      itemFields.addedBy = "TILCode";
      itemFields.subsOk = true;
      itemFields.substitutions = new List<String>();
      itemFields.private = false;
      itemFields.quantity = 1;
      itemFields.urgent = false;
      itemFields.isCrossedOff = false;

      var docRef = await db.createItem(args.parentListID, itemFields);

and edit_item_form.dart:

  @override
  void initState() {
    print(args.item.date.toString());
    itemFields.id = args.item.id;
    itemFields.name = args.item.name;
    itemFields.addedBy = args.item.addedBy;
    itemFields.date = args.item.date;
    itemFields.quantity = args.item.quantity;
    itemFields.subsOk = args.item.subsOk;
    itemFields.private = args.item.private;
    itemFields.urgent = args.item.urgent;
    itemFields.isCrossedOff = args.item.isCrossedOff;
    return super.initState();

To avoid having to delete my existing items and re-create them through the form just now, I also went into their Firebase documents and added isCrossedOff to my three test items:

Filtering the item stream by isCrossedOff’s value

Next up: adding a second “get item stream” method to database_manager.dart.

Originally, getItemsStream() returned all the items. Now, I want it to only return items where isCrossedOff is set to false, like so:

Stream<QuerySnapshot> getItemsStream(shoppingListID) {
    return shoppingLists.document(shoppingListID).collection('items').where('isCrossedOff', isEqualTo: false).snapshots();
}

A second stream method can return items where crossedOff is true.

Stream<QuerySnapshot> getCrossedOffStream(shoppingListID) {
    return shoppingLists.document(shoppingListID).collection('items').where('isCrossedOff', isEqualTo: true).snapshots();
  }

Back in main_shopping_list.dart I added another use of my ItemListStream widget and pass it db.getCrossedOffStream for the given shopping list ID:

return SingleChildScrollView(
  child: Column(
    mainAxisSize: MainAxisSize.min,
    mainAxisAlignment: MainAxisAlignment.start,
    children: <Widget>[
      ItemListHeader(text: args.list.id), 
      ItemListStream(dbStream: db.getItemsStream(args.list.id), listType: 'item', onTap: _crossOff, onInfoTap: _editItem, parentList: args.list),
      ItemListHeader(text: "Crossed off"),
      ItemListStream(dbStream: db.getCrossedOffStream(args.list.id), listType: 'item', onTap: _moveBack, onInfoTap: _editItem, parentList: args.list),
    ],

Now the items are being filtered by their isCrossedOff value:

Once I saw it working and was satisfied that I could filter the items this way, I decided to do a quick refactor to turn getItemsStream() in database_manager.dart into one method that takes a second parameter indicating whether I want the active items or the crossed off items.

  Stream<QuerySnapshot> getItemsStream(shoppingListID, isCrossedOff) {
    return shoppingLists.document(shoppingListID).collection('items').where('isCrossedOff', isEqualTo: isCrossedOff).snapshots();
  }

Which looks like this when I call getItemsStream():

 ItemListStream(dbStream: db.getItemsStream(args.list.id, false), listType: ...

Making crossed off items look “crossed off” by drawing a line through them

This step was easy – in main_shopping_list.dart, I changed the existing listType param from ‘item’ to ‘crossedOff’.

ItemListStream(dbStream: db.getItemsStream(args.list.id, true), listType: 'crossedOff', ... 

In item_list.dart, I added an additional check for the ‘crossedOff’ list type in the itemBuilder so that an Item instance it still created for it.

...

var listItem;

if (listType == 'shopping list') {
  listItem = ShoppingList(list[index]);
} else if (listType == 'store') {
  listItem = Store(list[index]);
} else if (listType == 'item' || listType == 'crossedOff') {
  listItem = Item(list[index]);
} else {
  print("Unhandled list item type");
}

list_item.dart was already set up to apply a lineThrough style if the listType is ‘crossedOff’.

return ListTile(
      title: Text(buildTitleString(), style: (listType == 'crossedOff' ? TextStyle(decoration: TextDecoration.lineThrough) : TextStyle(decoration: TextDecoration.none))),
...

Crossed off text result:

Moving items between the “active” list and the “crossed off” list

In main_shopping_list.dart, there are two methods that get passed all the way down to the Item instances:

  _crossOff(Item item) {
    print("Remove this id from this list: " + item.id);
  }

  _addToList(Item item) {
    print("Moving this item back to the list: " + item.id);
  }

They are fed into the ItemListStream as onTap: methods. Items that are ‘active’ get the _crossOff method:

ItemListStream(dbStream: db.getItemsStream(args.list.id, false), listType: 'item', onTap: _crossOff, onInfoTap: _editItem, parentList: args.list),

Items that are on the ‘crossedOff’ list get the _addToList method:

ItemListStream(dbStream: db.getItemsStream(args.list.id, true), listType: 'crossedOff', onTap: _addToList, onInfoTap: _editItem, parentList: args.list),

In my first attempt at this, I ended up with two methods that almost looked identical:

    _crossOff(Item item) async {
      await db.updateItemCrossedOffStatus(
          args.list.id,
          item.id,
          {
            'isCrossedOff': true,
            'lastUpdated': DateTime.now().toString()
          }
        );
    }

    _addToList(Item item) async {
      await db.updateItemCrossedOffStatus(
          args.list.id,
          item.id,
          {
            'isCrossedOff': false,
            'lastUpdated': DateTime.now().toString()
          }
      );
    }

They both call the same DatabaseManager method:

  Future updateItemCrossedOffStatus(String parentListID, String itemID, data) async {
    if (parentListID != null && parentListID.length > 0) {
      // adjust the shopping list's item count accordingly
      if (data['isCrossedOff']) {
        shoppingLists.document(parentListID).updateData({'itemCount': FieldValue.increment(-1)});
      } else {
        shoppingLists.document(parentListID).updateData({'itemCount': FieldValue.increment(1)});
      }
      // update the item itself
      DocumentReference itemDocRef = shoppingLists.document(parentListID).collection('items').document(itemID);
      Firestore.instance.runTransaction((Transaction tx) async {
        await tx.update(itemDocRef, data);
      }).catchError((e) {
        print(e.toString());
      });
    } else {
      print("ID is null/has no length");
    }
  }

I refactored them into one method that just flips whatever the item’s current value for isCrossedOff currently is:

    _updateCrossedOffStatus(Item item) async {
      await db.updateItemCrossedOffStatus(
          args.list.id,
          item.id,
          {
            'isCrossedOff': !item.isCrossedOff,
            'lastUpdated': DateTime.now().toString()
          }
        );
    }

Note: I initially set out to use the “ItemDTO” for this update, but I couldn’t figure out how to use it “partially” – ie, the only parts of the item I want to update are the lastUpdated and isCrossedOff fields, but the ItemDTO requires all of the fields to be present. I could copy the entire item into it, but I wonder if there’s just some better way to do this in general…

View this feature’s pull request.

Now the user can cross items off and add them back to the active list by tapping on them. Return to the feature work list.

Building a Flutter app, part 6: adding create, read, and update (“CRUD”) operations

Welcome to Part 6 of my Flutter app dev journal. Now that the Firebase connection is working, it’s time to add some “create”, “read”, and “update” functionality.

Full disclosure: I’m saving “delete” for later. This is just the “CRU” part of “CRUD”.

Seeding the database with the first bit of data

I decided to start with “shopping lists”, so the first collection I created in Firebase was shopping_lists and I gave it one document. Now there’s something to get from the database, and a collection to add to when new ones are made.

Note: I made an id field and copied the document’s ID into it so that the ID would be easily accessible on the record’s object.

Creating a database manager class

The DatabaseManager class is a singleton that’ll get imported into any file that needs to interact with the database. In my project, it is located in lib/db/database_manager.dart

This is the absolute simplest thing I could think of – all this does is get the shopping list records as a stream. I modeled it (loosely) on the examples found in this helpful tutorial.

database_manager.dart

import 'package:cloud_firestore/cloud_firestore.dart';

class DatabaseManager {

  final CollectionReference shoppingLists = Firestore.instance.collection('shopping_lists');

  Stream<QuerySnapshot> getShoppingListStream() {
    return shoppingLists.snapshots();
  }
}

Using the database manager to get records from the Firebase database

I went back to main.dart and imported the DatabaseManager.

import './db/database_manager.dart';

Then, in the “return”, I added reference to a _shoppingLists widget:

return SingleChildScrollView(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.start,
              children: <Widget>[
                ItemListHeader(text: headerShoppingLists),
                _shoppingLists(context),  <--- this is what's new

And then I created the new _shoppingLists widget, still in main.dart. It uses the StreamBuilder to get records (which may come in piecemeal from the database) and either display them as an ItemList or display an error.

  Widget _shoppingLists(BuildContext context) {
    return StreamBuilder(
        stream: db.getShoppingListStream(),
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Text('Error: ${snapshot.error}');
          }

          if (snapshot.hasData && !snapshot.data.documents.isEmpty) {
            return ItemList(list: snapshot.data.documents, listType: 'shopping list', onItemTap: _goToList, onInfoTap: _editList);

          } else {
            return Text("Error: no shopping list data");
          }
        }
    );
  }

I had to update ItemList to create a ListItem of the correct type, like so:

return ListView.builder(
        shrinkWrap: true, // gives it a size
        primary: false, // so the shopping and store lists don't scroll independently
        itemCount: list.length + 1,
        itemBuilder: (BuildContext context, int index) {
          if (index == list.length) {
            if (listType == 'crossedOff') {
              return DeleteAll();
            } else { // store, shopping list
              return AddNew(list: list, listType: listType);
            }
          } else {
            var listItem;

            if (listType == 'shopping list') {
              listItem = ShoppingList(list[index]);
            } else if (listType == 'store') {
              //listItem = Store(list[index]);
            }

            return ListItem(item: listItem, listType: listType, count: getCount(listItem), onTap: onItemTap, onInfoTap: onInfoTap);
          }
        }
    );

And I had to update the ShoppingList model in shopping_list.dart to build itself from a document, like so:

import 'package:cloud_firestore/cloud_firestore.dart';

class ShoppingList {
  String id;
  String name;
  String listType = 'shopping list';
  List itemIDs;

  ShoppingList(DocumentSnapshot document) {
    this.id = document['id'];
    this.name = document['name'];
    this.itemIDs = document['itemIDs'];
  }
}

This is when my database permissions error became obvious, as no data was actually coming in from the db even though I was ready to display it in the Flutter app.

Troubleshooting Firebase access denied (“ConnectionState.waiting” always being true)

When I tried to get my data from the db, I got a “Missing or insufficient permissions” error.

I checked the value of snapshot.connectionState and found that it was equal to ConnectionState.waiting all the time.

  Widget _shoppingLists(BuildContext context) {
    return StreamBuilder(
        stream: db.getShoppingListStream(),
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Text('Error: ${snapshot.error}');
          }

          if (snapshot.connectionState == ConnectionState.waiting) {
            return Text("waiting is true!");
          }
      ...

This StackOverflow post was helpful. This is where I discovered a newly created Firebase database does not allow access to anyone. It’s locked down by default.

The quick fix is to make read/write open to anyone.

By default, the rules are:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

I changed them to:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if true;
    }
  }
}

This is obviously a bad idea in the long term, but I should be able to change the rules to allow registered, authorized users access to their own records (and deny everyone else) and I plan to build that soon, so for now this is acceptable.

Here’s where we’re at now:

“Hardware store” and “Groceries” are from the database, so this is forward progress even if we lost the “Stores” list in the process.

“Stores” broke because the ItemList widget expects to be fed a document snapshot, and I am going to fix that next.

This is mostly a repeat of the steps I just did to make shopping_lists. First, I added a stores collection and populated it with one store…

And then I added a way to retrieve the store record(s) in database_manager.dart:

import 'package:cloud_firestore/cloud_firestore.dart';

class DatabaseManager {

  final CollectionReference shoppingLists = Firestore.instance.collection('shopping_lists');
  final CollectionReference stores = Firestore.instance.collection('stores');
  
  Stream<QuerySnapshot> getShoppingListStream() {
    return shoppingLists.snapshots();
  }

  Stream<QuerySnapshot> getStoresStream() {
    return stores.snapshots();
  }
}

And finally, create a _stores widget in main.dart that does the same thing _shoppingLists does:

Widget _stores(BuildContext context) {
    return StreamBuilder(
        stream: db.getStoresStream(),
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Text('Error: ${snapshot.error}');
          }

          if (snapshot.hasData && !snapshot.data.documents.isEmpty) {
            return ItemList(list: snapshot.data.documents, listType: 'store', onItemTap: _editStore, onInfoTap: _editStore);

          } else {
            return Text("Error: no store data");
          }
        }
    );

I also had to update the Store model in store.dart:

import 'package:cloud_firestore/cloud_firestore.dart';

class Store {
  String id;
  String name;
  String address;

  Store(DocumentSnapshot document) {
    this.id = document['id'];
    this.name = document['name'];
    this.address = document['address'];
  }
}

Now we have stores coming in from the database, too. Yay!

Adding new shopping lists and stores via the in-app forms

Creating the data manually in Firestore’s database dashboard is no fun, so it’s time to hook up the in-app forms to the real database.

I am going to begin by adding the “create a new shopping list” feature.

Data transfer object

For interactions with the database I like to use what’s called a “data transfer object”, it’s just a way of making sure the data sent to the db confirms to a certain structure. I created a new file, shopping_list_dto.dart and built it out as so:

class ShoppingListDTO {

  String name;
  String date;
  List itemIDs;

  String toString() {
    return 'name: $name, date: $date, storeIDs: $itemIDs';
  }

  Map<String, dynamic> toJson() => <String, dynamic> {
    'name': this.name,
    'date': this.date,
    'itemIDs': this.itemIDs,
  };
}

The “toJson” method will be useful when we need to format the data for insertion into the database.

Next, I modified new_shopping_list.dart:

import 'package:flutter/material.dart';
import 'package:grocery_go/db/database_manager.dart';
import 'package:grocery_go/db/shopping_list_dto.dart';

class NewShoppingList extends StatefulWidget {

  static const routeName = '/newShoppingList';

  NewShoppingList({Key key});

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

class _NewShoppingListState extends State<NewShoppingList> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Add new shopping list"),
      ),
      body: Center(
        child: Padding(
          padding: EdgeInsets.all(20),
          child: AddShoppingListForm(),
        ),
      ),
    );
  }
}

class AddShoppingListForm extends StatefulWidget {
  @override
  _AddShoppingListFormState createState() => _AddShoppingListFormState();
}

class _AddShoppingListFormState extends State<AddShoppingListForm> {
  final formKey = GlobalKey<FormState>();

  final DatabaseManager db = DatabaseManager();

  final newShoppingListFields = ShoppingListDTO();

  String validateStringInput(String value) {
    if (value.isEmpty) {
      return 'Please enter a name';
    } else return null;
  }

  void saveNewList(BuildContext context) {
    final formState = formKey.currentState;
    if (formState.validate()) {
      // save the form
      formKey.currentState.save();
      // this data is auto-generated when a new list is made
      newShoppingListFields.date = DateTime.now().toString();
      newShoppingListFields.itemIDs = new List<String>();
      // put this stuff in the db
      db.addShoppingList(newShoppingListFields);
      // confirm with a snack bar
      Scaffold.of(context).showSnackBar(
          SnackBar(content: Text('New list created: ' + newShoppingListFields.name))
      );
      // go back to main view
      Navigator.of(context).pop();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: formKey,
      child: Column(
        children: [
          TextFormField(
              autofocus: true,
              decoration: InputDecoration(
                  labelText: 'Shopping list name',
                  border: OutlineInputBorder()
              ),
              validator: (value) => validateStringInput(value),
              onSaved: (value) {
                newShoppingListFields.name = value;
              }
          ),
          RaisedButton(
            onPressed: () => saveNewList(context),
            child: Text('Save'),
          ),
        ],
      ),
    );
  }
}

What’s new:

  • Changed the “form” into an actual Form widget so it can behave like a proper Flutter form
  • Instantiates an instance of ShoppingListDTO called newShoppingListFields and fills it out using form data
  • Added a form key (which is how Flutter distinguishes forms from each other)
  • Added validator property to TextFormField and created a simple validation method (all it does is check that there’s any input at all)
  • Added onSaved property to TextFormField so it knows what to do when the form is saved
  • Added saveNewList method that runs validation and, if valid, sends this form data off to the db and returns the user to the main screen
  • Changed the way the date is generated, it’s now converted to a string before it goes to the db

Getting this all working took a bit of trial and error. I experimented with Timestamp and DateTime objects before settling on pushing the date to the db as a string. I also had to try a few things before I figured out how to push the empty itemIDs array into the db in a way that would be recognized (on retrieval) as having a length.

Here it is! Now the user can create a new list and see it on the main screen.

And here it is in the database:

Just one thing is missing: the form-created shopping list needs to have its “id” field filled in after the record is created, so that’s next up.

Grabbing the new record’s ID and saving it to the record

I’d like every document (so every item, every shopping list, every store, etc.) to store its own ID in a data field. This should be useful for editing entries later on.

I wasn’t completely sure how to approach this at first – there is no ID until the document is created, so I have to created something in order to get that ID back.

Fortunately, Firebase has some documentation covering this use case. When you call the .add method, a DocumentReference is returned.

I’m already using .add, like so:

  Future<DocumentReference> addShoppingList(ShoppingListDTO shoppingList) {
    return shoppingLists.add(shoppingList.toJson());
  }

But I don’t have an update function yet, so I created that next:

  Future<void> updateShoppingList(String id, ShoppingListDTO shoppingList) {
    return shoppingLists.document(id).updateData(shoppingList.toJson());
  }

This lets me grab ID that comes back and immediately update the document to have that ID, like so (in new_shopping_list.dart):

void saveNewList(BuildContext context) async {
  final formState = formKey.currentState;
  if (formState.validate()) {
    // save the form
    formKey.currentState.save();

    // this data is auto-generated when a new list is made
    newShoppingListFields.date = DateTime.now().toString();
    newShoppingListFields.itemIDs = new List<String>();

    // put this stuff in the db and get the ID that was created
    DocumentReference docRef = await db.addShoppingList(newShoppingListFields);

    // update the record to have the ID that was generated
    newShoppingListFields.id = docRef.documentID;
    db.updateShoppingList(docRef.documentID, newShoppingListFields);

    // confirm it with a snack bar
    Scaffold.of(context).showSnackBar(
        SnackBar(content: Text('New list created: ' + newShoppingListFields.name))
    );

    // go back to main view
    Navigator.of(context).pop();
  }
}

Now the document’s ID is duplicated into a field on that document:

Refactoring it a bit…

This works, but I don’t like that my DatabaseManager is basically foisting this work onto whatever code is calling it. We’re never going to create a document and then not immediately turn around and slap the ID into it, so I wanted to see if I could put encapsulate this work within database_manager.dart

Initially, I ran into trouble trying to create a DocumentReference – a Future<DocumentReference> is not a DocumentReference, it seems.

This might be a job for async/await, which I’ve used in JavaScript/TypeScript but have not yet attempted in Flutter/Dart, so here we go – now it’s async/awaited and returning that DocumentReference.

  Future<DocumentReference> addShoppingList(ShoppingListDTO shoppingList) async {
    DocumentReference docRef = await shoppingLists.add(shoppingList.toJson());
    shoppingLists.document(docRef.documentID).updateData({'id':docRef.documentID});
    return docRef;
  }

And then over here in new_shopping_list.dart, I removed the “update the ID” code:

void saveNewList(BuildContext context) async {
  final formState = formKey.currentState;
  if (formState.validate()) {
    // save the form
    formKey.currentState.save();

    // this data is auto-generated when a new list is made
    newShoppingListFields.date = DateTime.now().toString();
    newShoppingListFields.itemIDs = new List<String>();

    // put this stuff in the db
    var docRef = await db.addShoppingList(newShoppingListFields);
    print("Created record: " + docRef.documentID);

    // confirm it with a snack bar
    Scaffold.of(context).showSnackBar(
        SnackBar(content: Text('New list created: ' + newShoppingListFields.name))
    );

    // go back to main view
    Navigator.of(context).pop();
  }
}

(I later cleaned it up by removing var docRef = and the print statement, I just wanted those to confirm that everything was working the way I expected.)

And there we have it – now the work of updating the ID is done by the DatabaseManager, which I think is just a better design for this particular use case.

All of the code pertaining to hooking up to Firebase and getting creation and retrieval working can be found in this pull request. (Hey, we’re halfway to a CRUD app!)

Sorting the shopping lists by name (alphabetically) as they come in from Firebase

Before we move on and Firebase-ify the rest of the app, I want to fix the way shopping lists appear in a seemingly random (or at least unpredictable) order.

In the long run, it’d be nice if the user could re-order these lists, but for now, I think I’ll sort them alphabetically.

Firebase has .orderBy, but it took me a bit of trial and error to realize I had to apply it to the collectionReference, not the part where we get that reference in the first place (so not the Firestore.instance.collection("collectionName") part.

Like this:

class DatabaseManager {

  final CollectionReference shoppingLists = Firestore.instance.collection('shopping_lists');
  final CollectionReference stores = Firestore.instance.collection('stores');

  Stream<QuerySnapshot> getShoppingListStream() {
    return shoppingLists.orderBy("name").snapshots();
  }

  Stream<QuerySnapshot> getStoresStream() {
    return stores.orderBy("name").snapshots();
  }

  ...

Which results in the shopping lists being sorted by name:

Minor thing, but it was bothering me the way new ones didn’t seem to have any rhyme or reason to where they ended up in the list.

Hooking up the rest of the app to the database

Making the rest of the app work with Firebase was a decent amount of work, and most of it was a re-hash of what was already done above, but I still broke the major steps into individual pull requests for anyone interested in seeing them.

Subcollections

Working with Items (items being things like “eggs”, “bread”, etc.) made it apparent that they should be stored as a subcollection of a Shopping List, rather than have their IDs saved in an array on shopping list and retrieved separately.

Firebase seems to prefer you just stick the child document(s) inside their parent documents, rather than maintain a list of child document IDs the way I’m used to doing with MySQL databases.

In Firebase, the shopping list’s ‘items’ subcollection looks like this:

To create an item and add it to the subcollection, database_manager.dart now has the following method:

  Future<DocumentReference> createItem(String parentListID, ItemDTO item) async {
    // 1
    shoppingLists.document(parentListID).updateData({'itemCount': FieldValue.increment(1)});
    // 2
    DocumentReference itemDocRef = await shoppingLists.document(parentListID).collection('items').add(item.toJson());
    // 3
    itemDocRef.updateData({'id':itemDocRef.documentID});
    return itemDocRef;
  }
  1. Gets the parent shopping list document by its ID and updates “itemCount” (since we don’t have the itemIDs array to get the length of anymore)
  2. Gets the parent shopping list document by its ID, gets the collection of ‘items’ within, and adds the new Item (as JSON) to that subcollection of items
  3. Immediately gives that new Item its own ID as a field called ‘id’

The app now uses real data from the Firebase db for its shopping lists, items, and stores!

At this point the app has the most basic “create”, “read”, and “update” support for shopping lists, stores, and items, but the app needs a whole bunch of feature love to feel more polished.

Join me in Part 7 [Coming soon] as I add a bunch of new features.

Building a Flutter app, part 5: adding Firebase to the Flutter project

Welcome to part 5 of my Flutter App development journal, where I document my process of building a “grocery list” app from scratch.

In this post: Adding Firebase to my Flutter app. This is mostly setup (there was a fair amount of it) and the actual coding resumes in Part 6.

At this point, passing around the mock data objects is getting to be more of a nuisance than a help. I don’t want to write code just to support the passing-around of mock data, so it’s time to start putting that data into the db and retrieving it.

I decided to use Firebase for my project’s database needs. Flutter and Firebase are both Google projects and there is a lot of documentation that shows you how to use them together, so this seemed like a good place to start.

Setting up a new Firebase project

In the interest of not duplicating the official docs, I’ll just share the ones I followed:

But there were still a few places where I felt my experience deviated from the videos/docs or where I just kinda got lost, so I am documenting them here.

Registering the app with iOS

The Firebase instructions assume you’re working in Xcode, but if you’re like me and working with a different IDE (Android Studio) in my case you might be wondering how to get the iOS bundle ID.

Here’s what I did (and as far as I can tell, this step has to be done in Xcode.)

  1. Open Xcode
  2. Go to “File… Open” and open just the iOS directory of my project

3. Click on the “Runner” header in the project structure at left and retrieve the “Bundle identifier” from the information at right (just copy/paste it and leave XCode open because we come back to it in a later step).

4. Give that information to Firebase and click Register App

5. Download the GoogleService-Info.plist file it generates for you

6. Note that they tell you this file belongs in the “root of your Xcode” project. (The Flutter/Firebase video totally skips this step, too!) It took me some trial and error to discover that the right way to “install” this file is to drag it from Finder to the Runner folder in Xcode.

  • Do not do this operation just in Finder (or via Terminal), you must do this step in Xcode.
  • Make sure “Copy items if needed” is checked
  • Make sure “Add to targets Runner” is checked

If it looks like this, you’re done and can close Xcode:

7. Since this file contains things like my API key, I added *.plist to my .gitignore file (so you won’t see it if you browse the project there).

8. Back in Firebase setup, I skipped this step:

9. I also skipped the “Add initialization code” step which is for native projects.

Registering the app for Android

The Flutter/Firebase video covers this, but it happens so fast I had to watch it like 5 times to figure out where, exactly, this file is (and of course, pausing the video brings up a bunch of YouTube links and “related video” junk that covers the code, lol).

Search for and open AndroidManifest.xml:

The line in question is at the top, and the highlighted line is the part you need:

I do not actually have an Android device so for now I just have to hope this works, but I won’t know until later in development. I’ll come back and update this section if I have trouble using Firebase on an Android device.

Creating the Firebase database

After the project was successfully created on Google’s end, I created a new database and went with the default settings for now.

Note: I later had to change the “false” to “true”, because by default the Firebase database doesn’t allow access to anyone.

Adding the Firebase packages to pubspec.yaml

I added cloud_firestore and firebase_storage package dependencies to pubspec.yaml.

Now, return to Terminal and run flutter pub get (or if you are in Android Studio it might prompt you to update dependencies as soon as you flip over to another file).

Rebuilding the app

Finally, stop the app if you have it running and rebuild (hit the green “Play” button in Android Studio). Just a heads up, this particular build took a lot longer than they normally do.

Continue on to Part 6 where I write some basic create, read, and update operations for the app.

Building a Flutter app, part 4: refactors, improvements to the mock data, components

Welcome to part 4 of my Flutter App development journal, where I document my process of building a “grocery list” app from scratch.

In this post: Advanced interactions, improvements to the mock data, refactoring repeated code into components.

In the previous post, I built and linked up many of the app’s major routes, but there isn’t much to do on them yet. Changes made on one page aren’t really reflected on another, and a lot of code is repeated.

Now that most pages are represented, I wanted to refine the mock data, improve the way it gets passed around, refactor repeated code into components, and add more interactions such as moving items between lists.

This will be a short post, but this work should be done before moving onto the next step, which is hooking the app up to Firebase.

Refactoring the mock data

The mock data was useful for filling in the UI, but now I’d like to refine it.

  • Items need to exist independent of any particular list, so that they may appear on multiple lists and be moved between lists with ease
  • Lists should be more self-contained so that their data isn’t passed as multiple params; this object should contain the lists’s ID, name, and an array of item IDs
  • Default to local storage and pass data around, syncing with server whenever possible, but don’t prevent usage of the app in “offline” mode

The first thing I did was refactor it so the onTap functions all use a callback method passed in from main.dart or existing_shopping_list.dart. This meant I could finally remove all the “if … else” logic that checked what type of list the item belonged to from list_item.dart. (Sorry you had to see that, that was not my finest code, lol)

Here’s kind of a snippet of how that looks, from main.dart:

...

_editList(ShoppingList list) {
    Navigator.pushNamed(context, ExistingList.routeName, arguments: ExistingListArguments(list));
  }

@override
  Widget build(BuildContext context) {

    const headerShoppingLists = "Shopping Lists";
    const headerStores = "Stores";

    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: LayoutBuilder(
        builder: (BuildContext context, BoxConstraints viewportConstraints) {
          return SingleChildScrollView(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.start,
              children: <Widget>[
                ItemListHeader(text: headerShoppingLists),
                ItemList(list: shoppingLists, listType: 'shopping list', onItemTap: _goToList, onInfoTap: _editList),

And then, over here in item_list.dart, we continue passing the onItemTap and onInfoTap method handles down:

import 'package:flutter/material.dart';

import 'add_new.dart';
import 'delete_all.dart';
import 'list_item.dart';

class ItemList extends StatelessWidget {

  final list;
  final listType;
  final onItemTap;
  final onInfoTap;

  ItemList({Key key, @required this.list, @required this.listType, @required this.onItemTap, @required this.onInfoTap});

  @override
  Widget build(BuildContext context) {

    int getCount(item) {
      if (listType == 'shopping list') {
        return item.itemIDs.length;
      } else {
        return list.length;
      }
    }

    return ListView.builder(
        shrinkWrap: true, // gives it a size
        primary: false, // so the shopping and store lists don't scroll independently
        itemCount: list.length + 1,
        itemBuilder: (BuildContext context, int index) {
          if (index == list.length) {
            if (listType == 'crossedOff') {
              return DeleteAll();
            } else { // store, shopping list
              return AddNew(listType: listType);
            }
          } else {
            return ListItem(item: list[index], listType: listType, count: getCount(list[index]), onTap: onItemTap, onInfoTap: onInfoTap);
          }
        }
    );
  }
}

And then in list_item.dart, the passed-in methods are invoked on onPressed or on onTap, and this is where I pass back the entire item (we’ll see if I regret that decision later, for now passing the whole item makes it easy to pick it apart on the next page for its ID, name, and contents).

@override
Widget build(BuildContext context) {
return ListTile(
title: Text(buildTitleString(), style: (listType == 'crossedOff' ? TextStyle(decoration: TextDecoration.lineThrough) : TextStyle(decoration: TextDecoration.none))),
subtitle: Text(buildSubtitleString()),
leading: FlutterLogo(),
trailing: IconButton(
icon: Icon(Icons.info),
onPressed: () => onInfoTap(item),
),
onTap: () => onTap(item), //handleTap(context),
);
}

There are some other refactors in this pull request, too:

  • I added IDs to the mock data sets and restructured some of them
  • I renamed ome of the default Flutter boilerplate (“MyApp” is now “GroceryGoApp”, etc.)
  • I moved some existing code into new components, such as delete_all.dart and add_new.dart.

You can view all of these changes here.

On to database stuff

At this point, I can either continue building with mock data, passing it around and modifying it locally but really, it’s time to start putting it into/taking it out of a database. On to part 5!

Building a Flutter app, part 2: boilerplate, reusable components, and the main screen UI

Welcome to part 2 of my Flutter App development journal. In Part 1, I set up Flutter on my MacBook and documented some of the hurdles I encountered. In Part 2, I am going to build the main screen UI and many of the app’s reusable components.

In this post: Putting together the main screen’s UI using ordinary Flutter widgets. Features include: scrollable screen with multiple lists and headers, models for mock data, and displaying mock data.

To view all of the code associated with this step on GitHub, click here.

Creating the boilerplate Flutter app

Running the flutter create app_name command creates a folder with the app_name you give it and fill it with some starter code (called “boilerplate”) that you’ll either use or delete as you build your own app.

I like to build from the boilerplate app code you get from running flutter create, so I started with that.

Where to run flutter create

Run flutter create app_name in the folder where you want your codebase to be created. In other words, if you want it to be in /projects, go to projects and run it inside projects.

Note: Don’t do what I initially did, which was create the project folder, enter it, and run flutter create inside it. That will give you a folder structure like this: /projects/app_name/app_name/ and I assume you don’t want that.

I keep all of my projects inside a folder named projects, so I created my “Grocery Go” app from that directory:

cd projects
flutter create grocery_go 

That will give you /projects/grocery_go/

Planning the main screen UI

My favorite mockup tools all seem to have gone to a subscription model, so I’m just gonna sketch my UI plans real quick on this notecard here…

High tech!

The “goals” for this UI are:

  • Two lists of items, one to represent “Shopping Lists” and one to represent “Stores”.
  • Each list has a header
  • Neither list should be scrollable on its own (no “scrolling within scrolling”), but the whole screen should scroll up/down

Creating the “Header” reusable component

This is the part that displays the section title, like “Shopping Lists” or “Stores”, in the scrolling list on the main page.

I know I’m going to use that “Header” bar at least twice, so I made it its own component. I also created a /components directory to hold MainScreenListHeader.

(Perhaps this might be better termed a “widget”, but “component” was ingrained during my years of working as a web developer and it’s the first word I think of when describing reusable pieces of UI).

Anyway, I now have:

grocery_go/lib/components/main_screen_list_header.dart

I use underscores in the filenames and UpperCamelCasing for the class names inside, which is Dart convention.

Inside the file, I create a class called MainScreenListHeader that extends StatelessWidget and give it a constructor that takes this.text, which will serve as the title displayed in the header bar. This component returns a Container with a color, height, and a child Text widget.

import 'package:flutter/material.dart';

class MainScreenListHeader extends StatelessWidget {
  final String text;

  MainScreenListHeader({Key key, @required this.text});

  @override
  Widget build(BuildContext context) {
    return Container(
        color: Colors.blue[800],
        height: 30.0,
        child: Center(
            child: Text(text,
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 18,
                )
            )
        )
    );
  }
}

I wanted to pick a simple example for the first component, but the next one will be more complicated.

Creating the reusable “ListView” component

The list that contains individual shopping lists (such as “Groceries”, “House stuff”, etc.) and the list of the user’s stores (“Safeway”, “Fred Meyer”, etc.) appear in vertical lists the user can scroll up and down through.

Stores also display their address or location so the user can distinguish the Safeway near their office from the Safeway near their home.

Since I don’t know how many Shopping Lists or Stores a user might have saved, ListView builder seemed like a good widget to start with. I also put this in its own component from the beginning, which I named

grocery_go/lib/components/main_screen_list.dart

import 'package:flutter/material.dart';
import 'package:grocery_go/models/shopping_list.dart';

class MainScreenList extends StatelessWidget {

  final list;

  MainScreenList({Key key, @required this.list});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
        shrinkWrap: true, // gives it a size
        primary: false, // so it doesn't scroll
        itemCount: list.length,
        itemBuilder: (BuildContext context, int index) {
          return ListTile(
            title: Text(list[index].name),
            subtitle: Text(list[index] is ShoppingList ? list.length.toString() + ' items' : list[index].address),
          );
        }
    );
  }
}

Now all I need to do is pass in list objects from main.dart, which are declared as two separate lists of ShoppingList and Store objects.

I created two model files:

grocery_go/lib/models/shopping_list.dart

class ShoppingList {
final String name;

ShoppingList({this.name});
}

and

grocery_go/lib/models/store.dart

class Store {
  final String name;
  final String address;

  Store({this.name, this.address});
}

The last piece of the puzzle was to import the components and models in main.dart and change the body: property of the Scaffold that gets returned to use a LayoutBuilder that returns a SingleChildScrollView of a Column with children that are instances of my MainScreenListHeader and MainScreenList (whew – I’ll explain more after the code).

import 'package:flutter/material.dart';

import './components/main_screen_list.dart';
import './components/main_screen_list_header.dart';

import './models/shopping_list.dart';
import './models/store.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Grocery Go!'),
);
}
}

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

final String title;

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

class _MyHomePageState extends State<MyHomePage> {

// Some placeholder data just so we can see things working
final List<ShoppingList> shoppingLists = [
ShoppingList(name: "Groceries"),
ShoppingList(name: "House stuff"),
];

final List<Store> stores = [
Store(name: "Safeway", address: "Juanita"),
Store(name: "Safeway", address: "Bellevue"),
Store(name: "Home Depot", address: "Bellevue"),
Store(name: "Fred Meyer", address: "Kirkland"),
Store(name: "Fred Meyer", address: "Bellevue"),
Store(name: "Fred Meyer", address: "Ellensburg")
];

@override
Widget build(BuildContext context) {

const headerShoppingLists = "Shopping Lists";
const headerStores = "Stores";

return Scaffold(
appBar: AppBar(
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: LayoutBuilder(
builder: (BuildContext context, BoxConstraints viewportConstraints) {
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
MainScreenListHeader(text: headerShoppingLists),
MainScreenList(list: shoppingLists),
MainScreenListHeader(text: headerStores),
MainScreenList(list: stores),
],
),
);
}),
);
}
}

These are the major widgets used in this layout and why I picked them:

Layout Builder – this widget sizes itself to its children at runtime (among other things, but that’s why I picked it). Since I don’t know how many Shopping Lists or Stores the user has, I need to build the layout with that in mind.

SingleChildScrollView – This is a container that scrolls, and you can fill it with whatever you want. I have multiple ListViews inside it but I want them to scroll as a single unit, so I’m going to turn off their own independent scrolling ability and let the SingleChildScrollView handle it instead.

Column – I want my elements to appear in a vertical column, one after another: header, list, header, list, and a Column makes that easy. I had to set mainAxisSize: mainAxisSize.min to get it to work with everything else I have going on.

ListView.builder – I don’t know how many Stores or Shopping lists will be displayed, so I used ListView.builder. There were two important properties I had to set on it:

shrinkWrap: true – I had some trouble getting my ListView to work inside a Column at first. I kept getting a 'hasSize': is not true error. I found this StackOverflow post helpful, particularly Aziza’s reply.

primary: false – setting primary to false disables scrolling for this particular list. I want the SingleChildScrollView to handle the scrolling instead.

Here it is in Simulator: two ListViews inside a Column inside a SingleChildScrollView so they move as a unit. Hooray!

GitHub repo at this step.

Adding ‘add new…’ buttons to each list

The next thing this UI needs is a “Add new list…” and “Add new store…” button at the end of each list. Tapping these buttons will take the user to a page where they can create a list or a store.

I changed the MainScreenList widget to take a listType string, which I will use on the “add new” button itself:

class MainScreenList extends StatelessWidget {

  final list;
  final String listType;

  MainScreenList({Key key, @required this.list, @required this.listType});

...

Also in main_screen_list.dart, I changed itemCount to be the list length plus one more, so that last iteration can be used to draw the button at the end of the list, and the “listType” string that got passed in will used in the title, like so:

...
itemCount: list.length + 1,
itemBuilder: (BuildContext context, int index) {
  if (index == list.length) {
    return ListTile(
        title: Text("Add new " +  listType  + "...")
    );
... 

Back in main.dart, I now pass “shopping list” or “store” to MainScreenList.

children: <Widget>[
MainScreenListHeader(text: headerShoppingLists),
MainScreenList(list: shoppingLists, listType: "shopping list"),
MainScreenListHeader(text: headerStores),
MainScreenList(list: stores, listType: "store"),

Now there is a “Add new shopping list…” string at the end of the shopping lists:

And there is one at the end of the Stores list, too:

You can see these changes in this commit.

And that’s it for Part 2! Here’s a summary of what we did:

  • Drew a quick mockup of what the main screen of the app should look like
  • Ran flutter create to start the project
  • Built some reusable components that are used to build a list of Shopping Lists and a list of Stores
  • Populated those components with mock data
  • Made the main page scroll as one large list

Up next in Part 3: adding more screens and navigating between them.

Building a Flutter app, part 1: installation and setup

One of my favorite classes in OSU’s online CS degree program was CS492 Mobile Development. I’ve always wanted to make my own iOS/Android app and CS492 gave me enough of a foundation to attempt it.

In this multi-part “journal” series, I’ll share all the major steps and decisions I made as I developed a Flutter app.

In this post: some set-up hurdles I encountered on my MacBook. If you want to skip setup and go to the beginning of the code, go ahead to part 2.

Initial Flutter set up

For the most part, setting up Flutter was easy – I followed the official docs and it took me about 45 minutes to get the sample app running. (I’m on a new MacBook running macOS Catalina 10.15.3.)

I ran into a few hurdles, though, which I’ve documented here for other Mac users and for my future self (because I will inevitably go through this again someday).

Adding Flutter to the $PATH on a Mac

I followed the doc’s example of creating a “development” folder in my Users/mandi directory.

The full directory structure surrounding my “flutter” development directory.

I wanted to follow the Flutter documentation example for adding Flutter to my path permanently, but I didn’t know how to find the PATH_TO_FLUTTER_GIT_DIRECTORY on my machine. I know I made a ‘development’ folder, but I don’t know what’s “before” it in its file path.

As it turns out, there’s a handy trick you can do using Finder and a Terminal window to get the full path to a directory or file.

Dragging the ‘development’ folder into Terminal reveals its full path.

Dragging the “development” folder into a Terminal window gives the complete path you need for setting the $PATH.

That was what I needed to run:

export PATH="$PATH:/Users/mandi/development/flutter/bin"
source ~/.zshrc

Unfortunately, this didn’t “stick” on my machine. It worked for the Terminal window I was in, but as soon as I opened a new Terminal window the “flutter” command was no longer found.

It’s still in there if I echo $PATH, so I’m not sure what’s wrong here. But I’m not the only person who ran into this issue, as evidenced by this Stack Overflow post. Kaushik Bharadwaj’s answer explains how to add it to the path file permanently.

In a Terminal window, enter:

sudo nano /etc/paths

Add the full path to the flutter/bin folder [this is what mine looks like, yours may vary] to this file and save it:

/Users/YOURNAMEHERE/development/flutter/bin

On my machine, the paths file looks like this:

Now the “flutter” command persists through Terminal window closures, reboots, etc.

IDE for Flutter

There are a bunch of choices but I’ve only used Android Studio for Flutter development so far. I’m happy with it and it’s pretty good at formatting Flutter’s copious amounts of nested code.

If you choose Android Studio, beware that the default project setting might be “Android”. You can change that by clicking the little Project dropdown in the upper left corner and selecting “Project” instead of “Android”.

That should give you a nice view of the project structure, like so:

This step is unnecessary but it’ll get rid of the weird “Java” folder that isn’t represented in the project structure if you view it in Terminal or Finder and it’ll make your environment look like the one I’ll be sharing in screenshots throughout this journal.

Emulators

There are a few to pick from. I’m on a MacBook and I find that the iOS Simulator runs way better than the Android simulator. In Android Studio, they appear here in the top bar:

On a Mac it “just works” with the built-in Simulator (which is iOS-specific). I’m not sure what’s involved in other dev environments but I’ll come back to this when I try to build for Android later.

Final checks with Flutter Doctor

In Terminal, using flutter doctor will show the status of your flutter setup. You don’t actually need a checkmark next to everything in this list to start coding, but when it’s time to push your app onto a device you’ll want to clear out the “!” and “x” marks where appropriate.

Here is my flutter doctor output at the time of this writing. I’ll come back to these later when it’s time to test on an actual device – this is good enough to get started.

With the project set up, it’s time to move on to Part 2 where I’ll show you how I built the main page’s UI.

OSU eCampus CS492 Mobile Development review and recap

This post is part of an ongoing series recapping my experience in Oregon State University’s eCampus (online) post-baccalaureate Computer Science degree program. You can learn more about the program here.

Six-word summary: BEST COURSE IN THE PROGRAM 🥇

This class rules. Yong Bakos is a fantastic lecturer and course designer. CS 492 is the missing link between all the small-scope student projects you’ve done so far and the much larger, much more complex projects you’ll work on as a professional.

They call it “mobile development” because you’ll do this class’s work in Dart and Flutter, and see your work on a mobile device, but this class grows a ton of skills that are important no matter what you end up working on.

My advice: take this class even if you have no interest in mobile development.

CS492 Review

CS492 is what every class in this program should’ve been.

Every week introduces a batch of lectures that are actually enjoyable and useful. Follow along with the examples and what you build is directly useful for the projects you turn in every 2 weeks.

This class covers a huge range of topics, including:

  • Setting up a development and build environment above and beyond just running a file from the command line
  • Coding style, with concrete examples of what to do and what not to do
  • Working with an SDK
  • Reading and using API docs
  • Refactoring, “code smells”, best practices for structuring methods and projects as a whole
  • Design patterns that you can take to any language
  • Best practices (guards, import order, function design)
  • Actually building something!

I took CS492 the first time it was offered so I have to admit, I was pretty scared. Some OSU classes have taught me to temper my expectations and I was bracing for an uneven workload, messy, unfinished course content, unclear expectations, long-winded 50 minute lectures full of “ums” and mistakes that go uncaught, etc.

I mean, even the courses that have been running for longer than I’ve been a student suffer from all of these problems and they’ve had ample time to clean up, so why wouldn’t a brand new one be more of the same?

None of these things were a problem in CS492.

This class was organized, consistent, and predictable. The lectures are high-res, easy to follow, and enjoyable. You follow along with the professor as he builds a project from scratch. Each lecture is just 4-8 minutes long and covers a specific topic, so you don’t waste time scrubbing through a marathon hour-long lecture for the part you need. Early lectures are followed by a coding exercise built right into Canvas where you can complete a quick ungraded exercise to reinforce what you just learned.

The project grading rubrics were clear. The weekly discussions felt meaningful (for the most part), and I found myself skimming what other people contributed just to see what I could learn from them.

The projects felt like the start of something “real”, and I enjoyed the opportunity to use all the pieces of the framework. Overall, I learned a ton in this class and it was almost entirely smooth sailing.

The only things I didn’t like were:

  1. Initially, new modules unlocked on Mondays instead of Sundays (like almost all the courses do), making it hard to get a running start during the weekend. Many students complained and the instructor running the course (not the same person who recorded the lectures) started unlocking them Friday at midnight. This kind of responsiveness is fantastic. The early-unlocks ended around week 7, when I think the instructor was still editing the pre-recorded lectures. If they improve one thing in this class, I hope they make the next week’s content unlock earlier than Monday.
  2. Initially, the correct answers to the weekly quizzes remained hidden forever. During Week 3, the instructor changed a Canvas setting to release the quiz answers a day after the quizzes were due. Quizzes that just tell you what you got without telling you the right answers so you can learn from them are useless, so I’m glad the instructor listened to reason on this one, too.

I liked that the instructor (who was different from the guy who recorded the lectures) was responsive to student complaints about these things.

I also liked how this class hit the ground running. In the first week you get set up with Flutter and follow a well put-together tutorial that guides you through making a simple app. Wow, a simple app in the first week! The following weeks continue to add complexity, and the end result is a course with a very satisfying “deep dive” feel to it.

CS492 course structure

  • 10 weeks
  • There’s a book but I didn’t get it and managed an A without it
  • Weekly quizzes (took me about an hour apiece, they’re not trivial and there is some wording I would have changed here or there, but they aren’t horrible)
  • Class-wide Canvas-based discussion to post to by Wednesday of each week (the topic changes each week)
  • Response to someone else’s discussion post due by Sunday of each week (the response was usually fairly detailed, ie: write a rebuttal, provide sources, implement a some code to create what the OP was describing, etc.)
  • New material unlocks every Monday – later changed to early Saturday morning, later back to Monday much to everyone’s chagrin in the final weeks
  • 4 projects (the 3rd one was the hardest/most time-consuming, but they were all a fair bit of work)
  • Project submissions are both your code and a 90-120 second video (screen recording) of your project as you quickly step through it to demonstrate it meets the requirements
  • Proctored final exam worth 35% of the grade – note: in my run of the class, the final was changed to unproctored near the end of the quarter due to a lack of testing sites because of COVID-19

I cannot say enough good things about the quality of teaching in CS492.

Take this class. Take this class. Take this class.

Topics covered

  • Many Flutter-specific conventions like Widgets that, while they may seem specific to Flutter, have analogs to other libraries like React
  • Structuring and modularizing a project
  • Asynchronous programming (async/await)
  • Saving data to on-device storage
  • Saving user preferences
  • Hooking up to an external database with Firebase
  • Uploading and retrieving photos to/from cloud storage with Firebase
  • Integration testing in Flutter (lightly covered)
  • Debugging

Time commitment

I took CS492 alone, which gave me time to really dig into everything. I might’ve spent a little less time on it had I paired it with a second class, but every week was very consistent and worked out to about:

  • 3-4 hours watching the lectures, reading the accompanying explanations, and following along with the code (the lectures themselves are maybe 45-70 minutes of content each week but I paused a lot to follow along)
  • 2 hours on the weekly quiz
  • 1-2 hours on my weekly discussion post
  • 1-2 hours on my weekly discussion post reply
  • About 8-10 hours on the current project

That’s about 15-20 hours a week depending on the week. The weeks where projects were due tended to be closer to the “20” end of that spectrum than weeks where projects were introduced. This was mostly because the second week of each project tended to contain the lectures pertaining to the more challenging aspects of the project.

The projects spanned 2 weeks, but lectures unlock weekly, so you could only get through half the project the first week because the second half of lectures wouldn’t unlock until the next week.

For example:

  • Week A – Project assigned, needs Lectures from Week A and Week B to complete, only Lectures A are made available
  • Week B – Lectures B become available, project due at the end of the week

This got irritating at times. At the end of every “Week A” it felt like sitting in a holding pattern all weekend waiting for the second batch of lectures to unlock so I could start the second half of the project, but I always had enough to for the first part of the project and could fill the weekend.

My advice to other students in this holding pattern is to do absolutely everything you can do for the first half of the project in the first week. Leave nothing that could be done in “Week A” for “Week B”, and you’ll be fine.

Recording projects

This was my first class that asked for screen recordings and I was pretty intimidated at first. I do not like being recorded and tend to make a lot of mistakes, get really self-conscious, freeze up, etc. However, much to my relief, you don’t have to record yourself or any kind of voice-over, just your screen.

You just step through the script they provide you and after a few run-throughs you’ll nail it all in one take. I found it helpful to create a doc with an extremely detailed, click by click script to follow and have someone else read it to me. That way I didn’t have to switch between the script and my active recording, and didn’t lose my place so much.

On a Mac, it’s easy to make a screen recording using Quicktime. Just search for Quicktime Player, cancel the default dialog it opens, then right click it to start a new screen recording.

My recordings were usually around 80MB but Canvas accepted them just fine.

CS492’s lectures are fantastic

In general, the lectures in this online CS program are low quality. Many classes have lectures that are poorly paced, unedited, boring, or only loosely related to the assignment you’ll be doing that week.

CS492’s lectures are none of that. They are short, punchy, and purposeful. They’re organized by topic and easy to find later when you need to refer back to one.

You follow along with the lecturer as he codes from scratch, explaining every decision he makes. He gives tons of little tips, too (like “build frequently”, “don’t make a temp variable just to return it in the next line”, etc.) and he explains his reasoning as he splits things into methods.

It’s like getting to pair program with the most senior dev on your team. This screencap is from week 2 and while a lot of what he covered was review for me, I didn’t feel like he was wasting my time. I genuinely enjoyed following along with him.

Yes, I could use a second monitor.

Nothing in this class felt irrelevant. The work felt like real work (like the kind of work you’ll do at a job once you graduate from OSU). Week 2 was the only week that felt like a bit of a rehash (it covers starting a project, wiring up inputs, organizing things into functions, etc.) but it was a good review and it didn’t take up too much time.

(This process of breaking a program down into pieces, setting up the prints, the inputs, breaking it into modules, and reasoning through the project’s could be moved to CS162 where everyone will see it and learn from it early on.)

Whoa, Canvas has a repl?

I hope Canvas’s support for repl.it is a new feature, otherwise I’m going to be seriously salty that every previous class in this program missed this incredible teaching opportunity.

The repl exercises in CS492 aren’t graded, but they’re broken down into little bite-size pieces that take a few minutes to complete and do a great job of reinforcing the current topic.

Honestly, some of these exercises feel like entry level interview questions. I was grateful for the practice.

Even better (I didn’t discover this until Week 3), you can hook up repl.it to your GitHub account and your edits to the repls will be saved.

They’re public, which isn’t how I usually like to store my student work, but keeping organized backups of my work on these exercises was a big help.

Tips for CS492

Set up the Dart SDK and Flutter ahead of time, or at least early. The setup isn’t necessarily straightforward. In my case, it took about 2-3 hours to work through all the missing components of the install (I needed to upgrade RVM, Ruby, install Cocoapods in the right place, read half a dozen Stack Overflows from people with similar problems, etc.) Some people had a harder time than I did. Starting early is a good idea. You’re good to go when running flutter doctor gives you a clean output of checkmarks.

Get a second monitor (or use a second computer) to display the lectures on their own screen. I made it this far in this CS program before I felt like I needed a second monitor. If you can get two screens, do it – the lectures are much more enjoyable if you aren’t flipping between tabs/programs every few seconds.

Week 3 is also when I got clever and started watching the lectures on my PC and following along on my laptop. Not having to flip back and forth between Chrome and Visual Studio Code saved me a lot of time.

Quiz tips: Unlike some classes, the multiple choice options in this class’s quizzes are not obvious joke answers. Here, the wrong answers are often correct in some way, but don’t answer the question that was asked. Pick the answer that is both correct and most accurately answers the question. It’s sometimes subtle and difficult to spot at a glance. This same advice applies to the final.

Mac/MacBook tip: I worked in Android Studio because I liked it best of the options, but I found the Android emulator to run extremely slowly (like 2 frames a second) while the iOS Simulator ran like butter. You can invoke the iOS Simulator from Android Studio, so don’t let the “Android” label scare you off if you’re on a Mac.

Final exam tips: None of the questions were repeats from the quizzes, but reviewing all the quizzes and the writings that accompanied each lecture was time well-spent in preparation.

Due to COVID-19 shutting down so many test sites at the end of the Winter quarter, the final exam was changed to be unproctored. Future versions of this class might go back to a proctored final, but in my humble opinion, they should leave it unproctored.

The exam was still challenging, but since it was open book it felt like one last opportunity to learn something instead of a punitive experience. The questions aren’t readily Googleable and still require in-depth familiarity with the course’s material. I don’t think having access to the course’s material during the exam raised my final exam grade by more than a point or two, and the timed-but-open-book exam (which was still worth 35% of the final grade) felt more in line with the spirit of the class.

If you do face a proctored version of this final, you can take some comfort in knowing that if you paid attention all quarter long, you’ll do fine. Nothing on the exam was a surprise, and there wasn’t anything painful about it (no tracking memory registers by hand, no memorizing formulas and algorithms, no writing code into a blank Canvas form).

Take this class. Even if you have no interest in being a mobile developer, you’ll benefit from working in a more complex, real-world SDK than what previous classes exposed you to. This class also covers a ton of general-purpose topics that will refine your skills and be useful no matter what you do in your CS career.

If this class is indicative of the quality we can expect at OSU periodically revamps this program’s classes, then I’m very optimistic about the quality of OSU’s online CS program in the coming years. CS492 felt like getting to pair program with a senior engineer for a few hours a week, every single week. Thanks for the great class, Professor Bakos.

CS492 is the highest quality course in the OSU program. It does not run every quarter (I took it in Winter 2020) but it is worth waiting (or retooling your schedule) for.

And that’s it! The only class I have left is Capstone next quarter.

PS: Seriously, take this class.