Learning How To Flutter - Part 5 | iRyanBell.com
iRyanBell

Learning How To Flutter - Part 5

May 21, 2019

Learning How To Flutter - Part 5

Two steps forward, 1.999… steps back. I’ve been thinking about different ways to try to reduce the perceived complexity of my project.

While exploring the possibilities of writing functional code in Flutter with Dart and thinking about functions as a first class object, providing function definitions with their own descriptively named files (rather than grouping them inside utils.ext style wrappers) seemed like it could potentially be a step in the right direction.

Buttons meme

This is what I ended up with:

Directory Flow

This project directory structure certainly doesn’t seem any less complex. But, perhaps it isn’t any more complex than a less structured pile of functions.

Instead of using classes, I wanted to see what this build would be like using component builder functions in place of composed instance trees. For each component, I went with a Builder or StatefulBuilder widget for the main wrapper. This structure gave me the opportunity to create a directed graph of nested state setter functions and context objects then add whatever typed arguments I would need to build a component.

We begin at the main.dart launchpad:

main.dart

main() => runApp(app);

This function is called once when the application is first launched. It bootstraps the app onto the device.

This file has two siblings, an app directory and a component directory.

The app directory contains top-level dependencies, like the global configuration and theme, the application’s local storage functions, routes between the page scaffolds, and localized string resources.

app/app.dart

final MaterialApp app = MaterialApp(
  theme: theme['material'],
	home: homeDashboard,
);

app defines a MaterialApp instance with an initial page (homeDashboard) and a global theme.

app/config.dart

final Map config = {
  'root_list_id': 'list',
};

This config currently just stores an identifier value for the root list held in local storage. This is used as a reference for generating new ids for inner list item objects and for maintaining the ordering and contents of this top-level tree.

app/theme/theme.dart

final Map theme = {
  'completable': completable,
  'material': material,
};

The theme contains a key for material objects with sibling-level definitions for my own component themes.

app/theme/components/completable.dart

final Map completable = {
  'item': {
    'height': 50.0,
  },
};

Currently, the completable component has just one custom theme setting — the height of the item.

app/theme/material/material.dart

final ThemeData material = ThemeData(
  brightness: Brightness.dark,
  primaryColorDark: Colors.black87,
  secondaryHeaderColor: Colors.grey.shade900,
  accentColor: Colors.tealAccent,
  errorColor: Colors.redAccent,
  dividerColor: Colors.white10,
  unselectedWidgetColor: Colors.white54,
  appBarTheme: appBarTheme,
  snackBarTheme: snackBarTheme,
  textTheme: textTheme,
);

In the base material.dart theme file, I’m laying out global value-specific colors, like primaryColorDark, then putting the Material widget themes inside their own files.

app/theme/material/components/app_bar_theme.dart

final AppBarTheme appBarTheme = AppBarTheme(
  color: Colors.black87,
);

The AppBar is a dark grey color by default.

app/theme/material/components/snack_bar_theme.dart

final SnackBarThemeData snackBarTheme = SnackBarThemeData(
  actionTextColor: Colors.white,
  backgroundColor: Colors.red.shade500,
);

The SnackBar is currently only displayed when items are removed. So, it’s themed as white text on a red background.

app/theme/material/components/text_theme.dart

final TextTheme textTheme = TextTheme(
  title: TextStyle(
    color: Colors.white,
    fontSize: 15.0,
  ),
  subtitle: TextStyle(
    color: Colors.white54,
    fontSize: 12.0,
  ),
);

The textTheme is currently just used for Completable list items, with title and subtitle specifications.

app/resources/resources.dart

final Map resources = {
	'English': english,
}; 

Currently, the app only has labeling in English.

app/resources/locale/english.dart

final Map english = {
  'appBar': {
    'title': 'GSDist',
    'delete': 'Delete',
    'complete': 'Complete',
    'share': 'Share',
    'dictate': 'Dictate',
    'create': 'Create',
  },
  'dialog': {
    'create_list': {
      'title': 'Create a new list',
      'hint': 'Title',
    },
    'add_item': {
      'title': 'Add a new item',
    },
    'cancel': 'Cancel',
    'confirm': 'Confirm',
  },
  'drawer': {
    'label': 'Item list',
    'header': {
      'label': 'My Lists',
    },
  },
};

If the app is translated into other languages, the keys would remain the same while the values would be the translated equivalent.

app/routes/home_dashboard.dart

final StatefulWidget homeDashboard = StatefulBuilder(
  builder: (BuildContext ctx, StateSetter setState) => Scaffold(
    appBar: appBar,
    body: Scaffold(
      drawer: buildDrawer(setRouteState: setState),
    ),
  ),
);

This app is only using one route — the main dashboard. This dashboard has an AppBar on top and a Drawer which slides in from the left. Both the AppBar and Drawer are contained in the main Scaffold, but the Drawer is nested into a second, inner Scaffold to keep the AppBar fixed to the top of the screen, above the Drawer.

The dashboard generates its own state for the route and passes it to the drawer builder. This state is passed down through setRouteState: — if we call this function, every component will be redrawn from the Route level of the tree downward.

app/routes/utils/pop_navigator.dart

bool popNavigator({BuildContext ctx, bool returnValue}) {
  Navigator.of(ctx).pop();

  return returnValue;
}

There’s a small utility function associated with the routing logic, used to pop the global route navigator’s history’s state backward. It’s a little unintuitive at first, but this is used to close a dialog box, since the dialog box is shown with a forward navigator state push.

I added a parameter to define a boolean return value for the cancel / confirm dialog box actions. This allows me to set the returned Future from the dialog to the action taken by the user.

While a .pop() call is always used to close a dialog, there’s a difference between closing the dialog to cancel an event, and closing a dialog to confirm an event. Because my dialog boxes have a barrierDissmissible attribute set to true, the return value can originate from outside the context of the two provided dialog action buttons.

app/storage/del.dart

Future<bool> del({String key}) async {
  final SharedPreferences prefs = await SharedPreferences.getInstance();
  await prefs.remove(key);

  return true;
}

app/storage/int.dart

Future<int> getInt({String key}) async {
  final SharedPreferences prefs = await SharedPreferences.getInstance();
  final int value = prefs.getInt(key) ?? 0;

  return value;
}

Future<int> setInt({String key, int value}) async {
  final SharedPreferences prefs = await SharedPreferences.getInstance();
  await prefs.setInt(key, value);

  return value;
}

Future<int> incInt({String key, int value=1}) async {
  final SharedPreferences prefs = await SharedPreferences.getInstance();
  final int value = (prefs.getInt(key) ?? 0) + 1;
  await prefs.setInt(key, value);

  return value;
}

app/storage/map.dart

Future<Map> setMap({String key, Map value}) async {
  final SharedPreferences prefs = await SharedPreferences.getInstance();
  await prefs.setString(key, json.encode(value));

  return value;
}

Future<Map> getMap({String key}) async {
  final SharedPreferences prefs = await SharedPreferences.getInstance();
  final String mapJson = prefs.getString(key);

  return json.decode(mapJson);
}

app/storage/string_list.dart

Future<List<String>> getStringList({String key}) async {
  final SharedPreferences prefs = await SharedPreferences.getInstance();
  List<String> list = prefs.getStringList(key) ?? [];

  return list;
}

Future<List<String>> setStringList({String key, List<String> value}) async {
  final SharedPreferences prefs = await SharedPreferences.getInstance();
  await prefs.setStringList(key, value);

  return value;
}

Future<List<String>> appendToStringList({
  String key,
  String value,
  bool isSet = false}) async {
  final SharedPreferences prefs = await SharedPreferences.getInstance();
  List<String> list = prefs.getStringList(key) ?? [];

  if (isSet) {
    Set<String> listSet = list.toSet();
    listSet.add(value);
    list = listSet.toList();
  } else {
    list.add(value);
  }
    
  prefs.setStringList(key, list);

  return list;
}

app/storage/string.dart

Future<String> getString({String key}) async {
  final SharedPreferences prefs = await SharedPreferences.getInstance();

  return prefs.getString(key) ?? '';
}

Future<String> setString({String key, String value}) async {
  final SharedPreferences prefs = await SharedPreferences.getInstance();
  await prefs.setString(key, value);

  return value;
}

These local storage functions are used for getting and setting typed data into the device’s persistent storage. There wasn’t a direct way to handle Map objects, so I created a utility that converts a Map to a JSON String on the way in, then converts a JSON String to a Map on the way out. Currently, there’s no error handling for mismatched requests (eg. trying to getMap on a key that resolves to String when a JSON String was expected.)

Because each item in my list should have its own unique ID, I wanted to ensure this requirement was met at the storage level. I created an isSet parameter to the appendToStringList function. When this is set to true, the queried list of strings is converted to a Set of unique values. The provided value is added to the set only if it is a unique value, then the Set is converted back to a List and pushed into storage. This is a useful feature anytime duplicate data is to be avoided.

These processes are performed asynchronously, which introduces some rough edges to the reactive data flow. To illustrate one example: I’m using a root_list_id key for storing data about a given root-level list. This key contains an id generator at id:$root_list_id such that each item added to the list gets its own identifier defined as id:$root_list_id + 1. The details for that item are stored at $root_list_id:$item_id. There’s also a selected item preference value stored at $root_list_id:selected, which is set to some $item_id.

What this means in practice, is that a setState call for a Route will redraw a Drawer, but the rendering of that Drawer is dependent on the results of a FutureBuilder. The builder has to first query local storage for a list’s contents of item ids. Then, each of those items needs to be mapped to the corresponding item details. Each item is compared against the list’s selected value to determine whether or not that item is selected. This is a computationally slow process, although it’s imperceivably slow with small lists on fast devices. I could get around this issue with caching, but I’m not sold on the idea just yet.

The component directory is used to group the functions for each component. Currently, the components defined are: app_bar, completable, dialog, drawer, icon_button, list, and snack_bar.

components/app_bar/app_bar.dart

final AppBar appBar = AppBar(
  backgroundColor: theme['material'].appBarTheme.color,
  title: Text(resources['English']['appBar']['title']),
  leading: buildIconButton(
    icon: leading['icon'],
    label: leading['label'],
    action: leading['action'],
  ),
  actions: actionsList.map(
    (item) => buildIconButton(
      icon: item['icon'],
      label: item['label'],
      action: item['action'],
    )
  ).toList()
);

The AppBar has a leading button, a title, then several action button. Each button has an Icon, Label, and Action function. These buttons are generated from the icon_button component builder.

components/app_bar/actions/actions_list.dart

final List<Map> actionsList = [
	{
		'label': resources['English']['appBar']['complete'],
		'icon': Icons.check,
		'action': ({ BuildContext ctx }) {},
	},
	{
		'label': resources['English']['appBar']['share'],
		'icon': Icons.share,
		'action': ({ BuildContext ctx }) {},
	},
	{
		'label': resources['English']['appBar']['dictate'],
		'icon': Icons.mic,
		'action': ({ BuildContext ctx }) {},
	},
	{
		'label': resources['English']['appBar']['create'],
		'icon': Icons.add,
		'action': showCreateDialog,
	},
];

The current set of actions are for completing all tasks, sharing a list of tasks, dictating a list of tasks, or creating a new task.

Most of these actions still need to be written.

components/app_bar/actions/show_create_dialog.dart

void showCreateDialog({BuildContext ctx, StateSetter setState}) {
  showDialogBox(
    ctx: ctx,
    title: resources['English']['dialog']['add_item']['title'],
    content: TextField(
      autofocus: true,
      decoration: InputDecoration(
        hintText: 'Title',
      ),
    ),
    actionList: buildCancelConfirmActions(ctx: ctx),
  );
}

A new task creation process involves a dialog box with a TextField and two buttons — one to cancel the action, and one to confirm.

components/app_bar/leading/leading.dart

final Map leading = {
  'label': resources['English']['appBar']['delete'],
  'icon': Icons.delete,
  'action': ({ BuildContext ctx }) {},
};

The leading button will be used to delete a given list of tasks.

components/completable/build_completable.dart

final Container background = Container(
  color: theme['material'].accentColor,
);

final Container secondaryBackground = Container(
  color: theme['material'].errorColor,
);

final BoxDecoration borderBottomDecoration = BoxDecoration(
  border: Border(
    top: BorderSide(
      width: 0.5,
      color: theme['material'].dividerColor,
    ),
    bottom: BorderSide(
      width: 0.5,
      color: theme['material'].dividerColor,
    ),
  ),
);

StatefulWidget buildCompletable({
  List<Map> items,
  Map details,
  StateSetter setRouteState,
  StateSetter setDrawerState,
  Widget handle,
  String rootId}) => StatefulBuilder(
    builder: (BuildContext ctx, StateSetter setItemState) => Dismissible(
      key: Key(details['id']),
      onDismissed: (_) {
        dismiss(
          ctx: ctx,
          items: items,
          details: details,
          setDrawerState: setDrawerState,
          rootId: rootId,
        );
      },
      confirmDismiss: (direction) => direction == DismissDirection.endToStart
        ? confirmDismiss(details: details, ctx: ctx)
        : complete(setState: setItemState, details: details),
      secondaryBackground: secondaryBackground,
      background: background,
      child: Container(
        decoration: borderBottomDecoration,
        child: buildCompletableItem(
          details: details,
          handle: handle,
          rootId: rootId,
          setState: setRouteState,
        ),
      ),
    ),
  );

Each task item is built over a Dismissible widget, with two background colors split by the swipe direction.

components/completable/build_completable_item.dart

FlatButton buildCompletableItem({
  Map details,
  Widget handle,
  StateSetter setState,
  String rootId}) => FlatButton(
  padding: const EdgeInsets.all(0.0),
  onPressed: () => select(rootId: rootId, details: details, setState: setState),
  child: Row(
    children: <Widget>[
      details['completed'] ? completedIcon : Container(),
      Expanded(
        child: Container(
          alignment: Alignment.centerLeft,
          margin: const EdgeInsets.only(left: 10.0),
          height: theme['completable']['item']['height'],
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: buildDetails(
              selected: details['selected'],
              title: details['label'],
              subtitle: 'Details schmetails',
            ),
          ),
        ),
      ),
      handle,
    ],
  ),
);

A CompletableItem can be swiped right to complete or left to remove (with a confirmation required.) These can be reordered by dragging vertically on an item’s handle.

components/completable/build_details.dart

List<Widget> buildDetails({String title, String subtitle, bool selected}) {
  final TextStyle titleStyle = TextStyle(
    fontSize: theme['material'].textTheme.title.fontSize,
    color: selected
      ? theme['material'].accentColor
      : theme['material'].textTheme.title.color,
  );
  return [
    Text(
      title,
      style: titleStyle,
    ),
    Text(
      subtitle,
      style: theme['material'].textTheme.subtitle,
    )
  ];
}

The inner details of a CompletableItem vary by the selection status of the item.

components/completable/completed_icon.dart

final Container completedIcon = Container(
  height: theme['completable']['item']['height'],
  padding: const EdgeInsets.only(left: 8.0),
  child: Icon(
    Icons.check,
    color: theme['material'].accentColor,
  ),
);

When a CompletableItem has been completed, a completedIcon element is prepended to the item’s row.

components/completable/actions.dart

Future<bool> select({String rootId, Map details, StateSetter setState}) async {
  await setString(key: '$rootId:selected', value: details['id']);
  setState(() {});

  return true;
}

Future<bool> complete({StateSetter setState, Map details}) async {
  details['completed'] = !details['completed'];
  await setMap(key: details['id'], value: details);
  setState(() {});

  return false;
}

Future<bool> dismiss({
  String rootId,
  List<Map> items,
  Map details,
  BuildContext ctx,
  StateSetter setDrawerState}) async {
  final String snackBarTitle = '${details['label']} removed.';
  final int idx = items.indexOf(details);

  items.removeAt(idx);

  final List<String> itemIds = items.map((item) => item['id'].toString()).toList();
  await del(key: details['id']);
  await setStringList(key: rootId, value: itemIds);
  
  setDrawerState((){});
  showSnackBar(ctx: ctx, title: snackBarTitle);

  return true;
}

Future<bool> confirmDismiss({Map details, BuildContext ctx}) async {
  final String dialogBoxTitle = 'Remove ${details['label']}?';
  final List<Map> cancelConfirmActions = buildCancelConfirmActions(ctx: ctx);

  final Map dialogResult = await showDialogBox(
    ctx: ctx,
    title: dialogBoxTitle,
    actionList: cancelConfirmActions
  );

  return dialogResult['confirm'];
}

The actions performed on a given item affect state at 3 depths. A completion changes the state of the item (since only 1 item needs to be re-rendered). A dismissal or reorder mutates the state of the item list (since all items after the change need to be re-rendered). A selection mutates the state of the entire route, since the dashboard and the item react to the selected root item state.

components/dialog/build_cancel_confirm.dart

List<Map> buildCancelConfirmActions({BuildContext ctx}) => [
  {
    'label': resources['English']['dialog']['cancel'],
    'action': () => popNavigator(ctx: ctx, returnValue: false)
  },
  {
    'label': resources['English']['dialog']['confirm'],
    'action': () => popNavigator(ctx: ctx, returnValue: true)
  },
];

components/dialog/show_dialog_box.dart

Future<Map> showDialogBox({
  BuildContext ctx,
  bool barrierDismissible = true,
  String title,
  Widget content,
  List<Map> actionList}) async {
  bool isConfirmed = false;

  final GlobalKey<FormState> key = GlobalKey<FormState>();
  final Form form = Form(
    key: key,
    child: content ?? Container(width: 0, height: 0,),
  );

  await showDialog(
    context: ctx,
    barrierDismissible: barrierDismissible,
    builder: (ctx) => AlertDialog(
      title: Text(title),
      content: form,
      actions: actionList.map(
        (Map details) => details['label'] != null
          ? FlatButton(
              child: Text(details['label']),
              onPressed: () {
                isConfirmed = details['action']();
              },
            )
          : null
      ).toList(),
    ),
  );

  key.currentState.save();

  return Future.value({'confirm': isConfirmed});
}

A dialog generally contains 2 buttons in this app — one to confirm the dialog and one to cancel. An optional inner-contents slot is used to hold Form components. Upon confirmation of the dialog box, the form is saved and the values are extracted into a result Map. This allows the app to asynchronously handle user input.

components/drawer/build_drawer.dart

StatefulWidget buildDrawer({StateSetter setRouteState}) => StatefulBuilder(
  builder: (BuildContext ctx, StateSetter setState) => FutureBuilder(
    future: getFutureDrawerItems(),
    builder: (BuildContext ctx, AsyncSnapshot<List<Map>> snapshot) => Drawer(
      semanticLabel: resources['English']['drawer']['label'],
      child: ClipRect(
        child: Stack(
          children: <Widget>[
            Container(
              margin: EdgeInsets.only(top: theme['completable']['item']['height']),
              child: buildList(
                setRouteState: setRouteState,
                setDrawerState: setState,
                items: snapshot.data ?? [],
                rootId: config['root_list_id'],
              ),
            ),
            buildDrawerHeader(setState: setState),
          ],
        ),
      ),
    ),
  ),
);

The Drawer is tricky. To have a header at the top of the scrollable drawer frame would require the contents to contain the header. This works well on a simple ListView, but in the case of a draggable list view, this carries the side effect of being able to sort items above the header. Nested ListViews inside of ListViews generate infinite height errors unless you shrinkWrap the list component. After tweaking the construction, I ended up using a Stack with a fixed drawer header on top, and the draggable scroll list below it using a top margin. To prevent right-swiped items from flying off the drawer, I added a ClipRect wrapper.

components/drawer/get_future_drawer_items.dart

Future<List<Map>> getFutureDrawerItems() async {
  final List<String> list = await getStringList(key: config['root_list_id']);
  final List<Map> listMap = await Future.wait(
    list.map((String item) async {
      final Map details = await getMap(key: item);
      details['selected'] = await getString(key: '${config['root_list_id']}:selected') == details['id'];

      return details;
    })
  );

  return List.from(listMap);
}

The items for the drawer are built asynchronously. While async code has the potential to run very quickly in parallel, the optimizations are lost when you need to wait for asynchronous code to complete sequentially. I’d like to revisit this logic.

components/drawer/header/build_drawer_header.dart

Material buildDrawerHeader({StateSetter setState}) => Material(
  color: theme['material'].secondaryHeaderColor,
  child: Row(
    children: <Widget>[
      Expanded(
        child: Container(
          margin: EdgeInsets.only(left: 10.0),
          child: Text(
            resources['English']['drawer']['header']['label'],
          ),
        ),
      ),
      buildIconButton(
        icon: actionsList[0]['icon'],
        label: actionsList[0]['label'],
        action: actionsList[0]['action'],
        setState: setState,
      ),
    ],
  ),
);

The header for the drawer has one button used for creating new root-level lists.

components/drawer/header/actions/actions_list.dart

final List<Map> actionsList = [
	{
		'label': resources['English']['appBar']['create'],
		'icon': Icons.add,
		'action': showDialogAndCreateList,
	},
];

The button for this list runs a function to show a dialog, then create a list based on the input. It’s appropriately named showDialogAndCreateList.

components/drawer/header/actions/show_dialog_and_create_list.dart

Future<Map> showCreateDialog({BuildContext ctx}) async {
  final Map form = {};
  final Map dialogResult = await showDialogBox(
    ctx: ctx,
    title: resources['English']['dialog']['create_list']['title'],
    content: Container(
      child: TextFormField(
        decoration: InputDecoration(
          hintText: resources['English']['dialog']['create_list']['hint'],
        ),
        onSaved: (String val) => form['create_list'] = val,
        autofocus: true,
      ),
    ),
    actionList: buildCancelConfirmActions(ctx: ctx),
  );
  dialogResult['form'] = form;

  return dialogResult;
}

void showDialogAndCreateList({BuildContext ctx, StateSetter setState}) async {
  final Map dialogResult = await showCreateDialog(ctx: ctx);
  final String createListTitle = dialogResult['form']['create_list'];

  if (dialogResult['confirm'] && createListTitle.length > 0) {
    final int rootId = await incInt(key: 'id:${config['root_list_id']}');
    final String itemId = '${config['root_list_id']}:$rootId';
    final Map map =  {
      'id': itemId,
      'label': createListTitle,
      'completed': false,
    };
    await setMap(key: itemId, value: map);

    await appendToStringList(
      key: config['root_list_id'],
      value: itemId,
      isSet: true,
    );
  }

  setState((){});
}

This function increments the key for the root_list_id, then appends that key into the contents of the root_list_id list. It also creates a new details Map for the generated key. After this action has been performed, it sets the state of the Drawer to re-render a new list with the appended value.

components/icon_button/build_icon_button.dart

Widget buildIconButton({
  IconData icon,
  String label,
  Function action,
  StateSetter setState}) => Builder(
  builder: (BuildContext ctx) => IconButton(
    padding: const EdgeInsets.all(0.0),
    iconSize: 24.0,
    icon: Icon(icon),
    tooltip: label,
    onPressed: () => action(ctx: ctx, setState: setState),
  ),
);

Icon buttons are used for the DrawerHeader and the AppBar.

components/list/build_list.dart

DragList<Map> buildList({
  List<Map> items,
  StateSetter setRouteState,
  StateSetter setDrawerState,
  String rootId}) => DragList<Map>(
  items: items,
  onItemReorder: buildReorderHandler(items: items),
  itemExtent: theme['completable']['item']['height'],
  handleBuilder: (ctx) => handle,
  builder: (BuildContext context, Map details, Widget handle) => buildCompletable(
    handle: handle,
    items: items,
    details: details,
    setRouteState: setRouteState,
    setDrawerState: setDrawerState,
    rootId: rootId,
  ),
);

A DragList is used within the Drawer to house the Completable items.

components/list/build_reorder_handler.dart

Function buildReorderHandler({List<Map> items}) => (int oldIdx, int newIdx) async {
  final List<String> list = await getStringList(key: config['root_list_id']);
  final String itemMoved = list[oldIdx];
  list.removeAt(oldIdx);
  list.insert(newIdx, itemMoved);
  setStringList(key: config['root_list_id'], value: list);
  items.insert(newIdx, items.removeAt(oldIdx));
};

A DragList uses a function to generate a handle for dragging.

components/list/handle.dart

final Padding handle = Padding(
  padding: EdgeInsets.only(right: 12.0),
  child: Container(
    child: Icon(
      Icons.drag_handle,
      color: theme['material'].unselectedWidgetColor,
    ),
  ),
);

This handle is defined in the components/list/handle.dart file.

components/snackbar/showsnack_bar.dart

void showSnackBar({BuildContext ctx, String title}) {
  Scaffold.of(ctx)
    .showSnackBar(
      SnackBar(
        backgroundColor: theme['material'].snackBarTheme.backgroundColor,
        content: Text(
          title,
          style: TextStyle(
            color: theme['material'].snackBarTheme.actionTextColor,
          ),
        ),
      ),
    );
}

Finally, when items are removed from a list, a notification is displayed with a SnackBar to inform the user of the action performed.

So… without further ado, what does all of this do?

Demo https://www.youtube.com/watch?v=WV20tKLsgkk

You can download the project here: 👇
https://github.com/iRyanBell/learning-how-to-flutter_gsdist-part-5

I opened Instruments in Xcode and attached my Flutter runner to a memory management analysis tool with leak detection. Coming from React / ECMAScript, this is rarely a consideration. In Dart, it’s much less obvious to me what exactly is going on underneath the hood.

Instruments: Memory Usage

It looks like there are some issues in the memory managament. To dig deeper into the possible issue, I cloned the hello_world Flutter starter, and ran that through Instruments.

Instruments: Control Application

Low and behold, it too lit up with memory leak alerts. There’s less of an upward trend in memory usage, but the app does much less. So, I’m not quite sure how severe this issue is. Perhaps it’s a sort of heisenbug, where I’m seeing some artifact from the debugging tools.

Tomorrow, I’m debating whether to take a side step and revisit some other options (OOP?), or see where this direction leads. I might try to rethink the state management.

Looking at the way state flows through my project, I can see how some sort of Observable pattern with conditional state mutations could provide better reactivity than always mutating a component and all of its children with every change. This would decouple the nested layout structure from the structure of the application state. I was reading about the BLoC pattern — it’s interesting to note that one of the first warnings is that of Memory Leaks (is this going to be a recurring theme?):

https://medium.com/flutter-community/bloc-architecture-why-so-important-d9b29f06680e

https://medium.com/flutterpub/architecting-your-flutter-project-bd04e144a8f1

https://medium.com/flutter-community/flutter-bloc-with-streams-6ed8d0a63bb8

At first glance, BLoC looks a little heavy for what seems like a simpler abstraction in my head. To be continued…