iRyanBell

Learning How To Flutter - Part 4

May 19, 2019

Learning How To Flutter - Part 4

Turing Award winner Fred Brooks published a paper titled “No Silver Bullet – Essence and Accident in Software Engineering” in 1986.

http://faculty.salisbury.edu/~xswang/Research/Papers/SERelated/no-silver-bullet.pdf

Of all the monsters who fill the nightmares of our folklore, none terrify more than werewolves, because they transform unexpectedly from the familiar into horrors.

The familiar software project has something of this character (at least as seen by the non-technical manager), usually innocent and straightforward, but capable of becoming a monster of missed schedules, blown budgets, and flawed products. So we hear desperate cries for a silver bullet, something to make software costs drop as rapidly as computer hardware costs do.

I believe the hard part of building software to be the specification, design, and testing of this conceptual construct, not the labor of representing it and testing the fidelity of the representation. We still make syntax errors, to be sure; but they are fuzz compared to the conceptual errors in most systems. If this is true, building software will always be hard. There is inherently no silver bullet.

Software entities are more complex for their size than perhaps any other human construct, because no two parts are alike (at least above the statement level). If they are, we make the two similar parts into one, a subroutine, open or closed. In this respect software systems differ profoundly from computers, buildings, or automobiles, where repeated elements abound. Digital computers are themselves more complex than most things people build; they have very large numbers of states. This makes conceiving, describing, and testing them hard. Software systems have orders of magnitude more states than computers do.

Continuing in my journey to create this application, I’ve created a series of new components and utilities.

We begin at a main.dart file which bootstraps the application onto the device:

Main:

import 'package:flutter/material.dart';
import 'app.dart';

void main() => runApp(gsdistApp);

This application deployed onto the device is defined inside an app.dart file:

App:

import 'package:flutter/material.dart';
import './theme.dart';
import './dashboard.dart';

final MaterialApp gsdistApp = MaterialApp(
  theme: gsdistTheme['material'],
  home: gsdistDashboard,
);

My gsdistTheme variable is used to provide a global source of style for all components to reference within the app.

My current structure for this theme looks like:

Theme:

import 'package:flutter/material.dart';

final Map gsdistTheme = {
  'draglist': {
    'height': 50.0,
  },
  'material': ThemeData(
    brightness: Brightness.dark,
    appBarTheme: AppBarTheme(
      color: Colors.black87,
    ),
    primaryColorDark: Colors.black87,
    secondaryHeaderColor: Colors.grey.shade900,
    accentColor: Colors.tealAccent,
    errorColor: Colors.redAccent,
    dividerColor: Colors.white10,
    snackBarTheme: SnackBarThemeData(
      actionTextColor: Colors.white,
      backgroundColor: Colors.red.shade500,
    ),
    textTheme: TextTheme(
      title: TextStyle(
        color: Colors.white,
        fontSize: 15.0,
      ),
      subtitle: TextStyle(
        color: Colors.white54,
        fontSize: 12.0,
      ),
    ),
  ),
};

When laying something like this out, it’s not often clear whether it makes sense to group the logic at the component level (eg. Nav: Colors, Button: Colors), or at the attribute level (eg. Colors: Nav, Colors: Buttons). Since some components will share similar values across some attributes, it’s also logical to structure definitions at the abstraction level of the attribute value itself or the hiearchy of values (eg. Primary: Colors: Accent).

The outer MaterialApp wrapper configures the top-level Navigator to search for routes in the following order:

  1. For the / route, the home property, if non-null, is used.
  2. Otherwise, the routes table is used, if it has an entry for the route.
  3. Otherwise, onGenerateRoute is called, if provided. It should return a non-null value for any valid route not handled by home and routes.
  4. Finally if all else fails onUnknownRoute is called.

At this stage, there is just single page on the application, which feeds into the MaterialApp home parameter. I’m currently referring to this page as the application Dashboard.

Dashboard:

import 'package:flutter/material.dart';
import 'appbar/appbar.dart';
import './drawer/drawer.dart';

final Scaffold gsdistDashboard = Scaffold(
  appBar: gsdistAppBar,
  body: Scaffold(
    drawer: gsdistDrawer,
  ),
);

By nesting a Scaffold inside a main Scaffold, the main AppBar will remain fixed to the top of the UI, while a Drawer can slide underneath it.

My AppBar is currently spread across 4 files. The appbar.dart file itself contains the main component. buttons.dart contains the icon button items. actions.dart contains the onPressed actions. And finally, utils.dart contains a helper utility to create the bar’s items with their associated functions.

App Bar - App Bar:

import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import '../resources.dart';
import '../theme.dart';
import './buttons.dart';
import './utils.dart';

final AppBar gsdistAppBar = AppBar(
  backgroundColor: gsdistTheme['material'].appBarTheme.color,
  title: Text(gsdistResources['English']['appBar']['title']),
  leading: gsdistAppBarLeading.map(
    (item) => createAppBarButton(
      icon: item['icon'],
      label: item['label'],
      action: item['action'],
    )
  ).first,
  actions: gsdistAppBarActions.map(
    (item) => createAppBarButton(
      icon: item['icon'],
      label: item['label'],
      action: item['action'],
    )
  ).toList()
);

App Bar - Buttons:

import 'package:flutter/material.dart';
import '../resources.dart';
import './actions.dart';

final List<Map> gsdistAppBarLeading = [
  {
    'label': gsdistResources['English']['appBar']['delete'],
    'icon': Icons.delete,
    'action': ({ BuildContext ctx }) {},
  }
];

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

App Bar - Actions:

import 'package:flutter/material.dart';
import '../resources.dart';
import '../dialog/utils.dart';

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

  return returnValue;
}

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

void appBarAdd({BuildContext ctx}) {
  showModal(
    ctx: ctx,
    title: gsdistResources['English']['dialog']['add']['title'],
    actionDetailsList: createCancelConfirmActions(ctx: ctx),
  );
}

App Bar - Utils:

import 'package:flutter/material.dart';

Widget createAppBarButton({IconData icon, String label, Function action}) => Builder(
  builder: (BuildContext ctx) => IconButton(
    iconSize: 24.0,
    icon: Icon(icon),
    tooltip: label,
    onPressed: () => action(ctx: ctx),
  ),
);

The labeling for these buttons is being pulled in from a resources.dart file, where I’m currently defining a localized source of global text for the application.

Resources:

final Map gsdistResources = {
  'English': {
    'appBar': {
      'title': 'GSDist',
      'delete': 'Delete',
      'complete': 'Complete',
      'share': 'Share',
      'dictate': 'Dictate',
      'create': 'Create',
    },
    'dialog': {
      'add': {
        'title': 'Add a new item',
      },
      'cancel': 'Cancel',
      'confirm': 'Confirm',
    },
    'drawer': {
      'label': 'Item list',
    },
  },
};

In order to push a modal dialog onto the screen, I have a dialog/utils.dart file which contains:

Dialog: Utils:

import 'package:flutter/material.dart';

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

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

  return Future.value(isConfirmed);
}

This component currently has a format of displaying a title: string and an inner Widget component, followed by a cancel or confirm series of actions. By default, the user is able to hide the dialog by tapping outside of the popup.

Similar to the modal dialog, I have a snackbar/utils.dart helper utility to push small status updates onto the bottom of the main view.

Snackbar: Utils:

import 'package:flutter/material.dart';
import '../theme.dart';

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

A slidable drawer will be used to contain a list of lists.

The Drawer is my most complex UI component so far. A user will be able to drag inward from left edge of the device to reveal a shelf containing a list of completable tasks lists. This shelf implements a scroll along the vertical axis. A tap outside the drawer or a drag event on the background of the drawer along the horizontal axis will hide the component.

GSDist - Task removal

Each item contains a status indicator, a title, a subtitle, and a drag handle. The drag handle is used to initiate an animated resort event, triggered by a vertical drag. On a given item, a right-to-left swipe begins a task removal process with an asynchronous action performed after the chosen Future<bool> modal event fires, while a left-to-right swipe is used to toggle the status of a task.

A setState lambda reactively propagates state downward from the individual item level upon task completion, whereas removal of an item mutates the state down the tree from the higher Drawer node. A tap on an item will be used to select a given task list.

Drawer: Drawer:

import 'package:flutter/material.dart';
import 'package:drag_list/drag_list.dart';
import '../theme.dart';
import '../resources.dart';
import './items.dart';
import '../list/utils.dart';

final Widget gsdistDrawer = StatefulBuilder(
  builder: (BuildContext ctx, StateSetter setDrawerState) => Drawer(
    semanticLabel: gsdistResources['English']['drawer']['label'],
    child: ClipRect(
      child: DragList<Map>(
        items: drawerItems,
        itemExtent: gsdistTheme['draglist']['height'],
        builder: (BuildContext context, Map details, Widget handle) => createCompletable(
          handle: handle,
          drawerItems: drawerItems,
          details: details,
          setDrawerState: setDrawerState,
        ),
      ),
    ),
  ),
);

Drawer: Items:

final List<Map> drawerItems = List<Map>.generate(
  5,
  (i) => {
    'id': 'item-$i',
    'idx': i,
    'label': 'Item ${i + 1}',
    'completed': false,
    'selected': i == 0
  }
);

List: Completed:

import 'package:flutter/material.dart';
import '../theme.dart';

final Container completedItem = Container(
  height: gsdistTheme['draglist']['height'],
  padding: const EdgeInsets.only(left: 8.0),
  child: Icon(
    Icons.check,
    color: gsdistTheme['material'].accentColor,
  ),
);

List: Style:

import 'package:flutter/material.dart';
import '../theme.dart';

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

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

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

List: Utils:

import 'package:flutter/material.dart';
import '../dialog/utils.dart';
import '../appbar/actions.dart';
import '../snackbar/utils.dart';
import '../theme.dart';
import './completed.dart';
import './style.dart';

void dismissItem({List<Map> drawerItems, Map details, BuildContext ctx, StateSetter setState}) {
  setState(() => drawerItems.removeAt(details['idx']));
  showSnack(
    ctx: ctx,
    title: '${details['label']} removed.',
  );
}

Future<bool> confirmItemDismiss({Map details, BuildContext ctx}) => showModal(
  ctx: ctx,
  title: 'Remove ${details['label']}?',
  actionDetailsList: createCancelConfirmActions(ctx: ctx)
);

Future<bool> completeItem({StateSetter setState, Map details}) {
  setState(() {
    details['completed'] = !details['completed'];
  });

  return Future.value(false);
}

List<Widget> createCompletableDetails({ String title, String subtitle }) => [
  Text(
    title,
    style: gsdistTheme['material'].textTheme.title,
  ),
  Text(
    subtitle,
    style: gsdistTheme['material'].textTheme.subtitle,
  )
];

Row createCompletableItem({Map details, Widget handle}) => Row(
  children: <Widget>[
    details['completed'] ? completedItem : Container(),
    Expanded(
      child: Container(
        alignment: Alignment.centerLeft,
        margin: const EdgeInsets.only(left: 10.0),
        height: gsdistTheme['draglist']['height'],
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: createCompletableDetails(
            title: details['label'],
            subtitle: 'Details schmetails',
          ),
        ),
      ),
    ),
    handle,
  ],
);

Widget createCompletable({
  List<Map> drawerItems,
  Map details,
  StateSetter setDrawerState,
  Widget handle}) => StatefulBuilder(builder: (BuildContext ctx, StateSetter setState) => Dismissible(
    key: Key(details['id']),
    onDismissed: (_) {
      dismissItem(
        ctx: ctx,
        drawerItems: drawerItems,
        details: details,
        setState: setDrawerState,
      );
    },
    confirmDismiss: (direction) => direction == DismissDirection.endToStart
      ? confirmItemDismiss(details: details, ctx: ctx)
      : completeItem(setState: setState, details: details),
    secondaryBackground: secondaryBackground,
    background: background,
    child: Container(
      decoration: borderBottomDecoration,
      child: createCompletableItem(details: details, handle: handle),
    ),
  ),
);

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

Currently, when an item is removed, the index of the item to remove is defined at the item-level. This will need to be revised for compatibility with the sort function. In the next phase, I plan on moving the item list into local storage and revisiting the sorted data mutation.