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.