Deleting entry and undoing deletion in snackbar – Flutter – WeightTracker 7

Deleting entry and undoing deletion in snackbar – Flutter – WeightTracker 7

In this post I will go through how I added delete functionality to my WeightTracker app. I will also show how to implement ‘undo’ action on Snackbar with help from Redux Library. 🙂 Let’s get to it!

Note: I am using Redux library for application’s state manipulation. If you are not fimiliar with Redux concept, I highly recommend reading my post about it before going forward.

Delete action

Logic

At first I implemented logic behind removing an entry. It is quite straight forward.
I added two actions:

class RemoveEntryAction {
  final WeightEntry weightEntry;

  RemoveEntryAction(this.weightEntry);
}
class OnRemovedAction {
  final Event event;

  OnRemovedAction(this.event);
}

Then in the middleware function I implemented performing request to Firebase:

firebaseMiddleware(Store<ReduxState> store, action, NextDispatcher next) {
  ...
  if (action is RemoveEntryAction) {
    store.state.mainReference.child(action.weightEntry.key).remove();
  }
  
  next(action);

  if (action is UserLoadedAction) {
    store.dispatch(new AddDatabaseReferenceAction(FirebaseDatabase.instance
      .reference()
      .child(store.state.firebaseUser.uid)
      .child("entries")
    ..onChildAdded
        .listen((event) => store.dispatch(new OnAddedAction(event)))
    ..onChildChanged
        .listen((event) => store.dispatch(new OnChangedAction(event)))
    ..onChildRemoved
        .listen((event) => store.dispatch(new OnRemovedAction(event)))));
  }
}

The only thing RemoveEntryAction does is telling middleware to remove entry from database. When it’s done listener gets called and invokes OnRemovedAction which will change our local state.
Next step is to change reducer method:

ReduxState stateReducer(ReduxState state, action) {
  ReduxState newState = state;
  ...
  if (action is OnRemovedAction) {
    newState = _onEntryRemoved(state, action.event);
  }
  ...
  return newState;
}

ReduxState _onEntryRemoved(ReduxState state, Event event) {
  WeightEntry removedEntry = state.entries.singleWhere((entry) => entry.key == event.snapshot.key);
  List<WeightEntry> entries = <WeightEntry>[]
    ..addAll(state.entries)
    ..remove(removedEntry)
    ..sort((we1, we2) => we2.dateTime.compareTo(we1.dateTime));
  return state.copyWith(
    entries: entries,
    lastRemovedEntry: removedEntry,
    hasEntryBeenRemoved: true,
  );
}

When OnRemovedAction is invoked, all I do is remove entry from local list and pass new list to new state. As you can see, I also pass removed entry and flag confirming that entry has been removed. I will explain it in next chapter so don’t worry about that now. 🙂

At this point we have implemented whole logic behind removing entry from list using Redux and Firebase. Now let’s move to UI.

User interface

In my app, users can delete entry only by going to full screen edit dialog and clicking DELETE. When I created that dialog (described here) I designed it to return a WeightEntry. However since I introduced DELETE action simple model cannot contain this information. That’s why my dialog is now returning Redux Action.

Now let’s get into code. The only change I needed to do in my dialog class was adding DELETE action into AppBar:

Widget _createAppBar(BuildContext context) {
  bool isInEditMode = widget.weighEntryToEdit != null;
  return new AppBar(
    title: isInEditMode ?
      const Text("Edit entry") : const Text("New entry"),
    actions: [
      isInEditMode ? new FlatButton(
        onPressed: () {
          Navigator.of(context).pop(new RemoveEntryAction(widget.weighEntryToEdit));
        },
        child: new Text('DELETE', style: Theme.of(context).textTheme.subhead.copyWith(color: Colors.white)),
      ) : new Container(),
      new FlatButton(
        onPressed: () {
          WeightEntry entry = new WeightEntry(_dateTime, _weight, _note);
          var returnedAction = isInEditMode
              ? new EditEntryAction(entry)
              : new AddEntryAction(entry);
          Navigator.of(context).pop(returnedAction);
        },
        child: new Text('SAVE',
            style: Theme
                .of(context)
                .textTheme
                .subhead
                .copyWith(color: Colors.white)),
      ),
    ],
  );
}

What I do is I check if there is WeightEntry to edit, if so I create action containing FlatButton which will return proper redux action. If there no entry to edit (meaning also no entry to delete) I display empty Container instead of Button.

When it comes to invoking result action coming from dialog, I implemented it in following way:

_openEditEntryDialog(WeightEntry weightEntry, BuildContext context,
    Function(EditEntryAction) editEntryCallback,
    Function(RemoveEntryAction) removeEntryCallback) async {
  Navigator.of(context).push(
    new MaterialPageRoute(
      builder: (BuildContext context) {
        return new WeightEntryDialog.edit(weightEntry);
      },
      fullscreenDialog: true,
    ),
  )
      .then((result) {
    if (result != null) {
      if (result is EditEntryAction) {
        result.weightEntry.key = weightEntry.key;
        editEntryCallback(result);
      } else if (result is RemoveEntryAction) {
        result.weightEntry.key = weightEntry.key;
        removeEntryCallback(result);
      }
    }
  });
}

Where removeEntryCallback parameter is (action) => store.dispatch(action);.

Now we have fully working delete functionality using Firebase and Redux. Next step is to allow user to undo his action in case of a mistake.

Undo action

Logic

To perform undo action, it is required to save removed entry. To do that I added new field in redux state:

class ReduxState {
  ...
  final WeightEntry lastRemovedEntry;
  ...
}

I am saving it when weight entry gets deleted (showed 4 listings above).

Then I have to introduce undo action, there is nothing surprising in that:

class UndoRemovalAction {}

And actual logic would look like this:

firebaseMiddleware(Store<ReduxState> store, action, NextDispatcher next) {
  ...  
  if (action is UndoRemovalAction) {
    WeightEntry lastRemovedEntry = store.state.lastRemovedEntry;
    store.state.mainReference
        .child(lastRemovedEntry.key)
        .set(lastRemovedEntry.toJson());
  }

  next(action);
  ...
}

It’s that simple! I just get the value from state and push it to Firebase. When child is added, it will call same listener as in normal addition.

User interface

Now let’s add Snackbar on which user can click UNDO button. Since every build method is invoked when state gets changed, I had to store information if snackbar should be displayed. I did it by simply adding flag to state class:

class ReduxState {
  ...
  final WeightEntry lastRemovedEntry;
  final bool hasEntryBeenRemoved; //in other words: should show snackbar?
  ...
}

I set this flag to true when entry is getting removed.
Then, in actual Widget displaying Snackbar I added following code:

class ViewModel {
  final bool hasEntryBeenRemoved;
  final Function() acceptEntryRemoved;
  final Function() undoEntryRemoval;
  ...
}
@override
Widget build(BuildContext context) {
  return new StoreConnector<ReduxState, HistoryPageViewModel>(
    converter: (store) {
      return new ViewModel(
        ...
        hasEntryBeenRemoved: store.state.hasEntryBeenRemoved,
        acceptEntryRemovedCallback: () =>  store.dispatch(new AcceptEntryRemovalAction()),
        undoEntryRemoval: () => store.dispatch(new UndoRemovalAction()),
      );
    },
    builder: (context, viewModel) {
      if (viewModel.hasEntryBeenRemoved) {
        new Future<Null>.delayed(Duration.ZERO, () {
          Scaffold.of(context).showSnackBar(new SnackBar(
            content: new Text("Entry deleted"),
            action: new SnackBarAction(
              label: "UNDO",
              onPressed: () => viewModel.undoEntryRemoval(),
            ),
          ));
          viewModel.acceptEntryRemovedCallback();
        });
      }
  ...
}

How it works:

  1. When Entry is removed, hasEntryBeenRemoved flag is set up to true and setState function gets invoked.
  2. In build function (if flag is set to true) I create a Scaffold which has action with UndoRemoval which was described earlier.
  3. AcceptEntryRemovalAction gets invoked. What it does is change hasEntryBeenRemoved flag to false, so that we won’t show snackbar on every build.

If user clicks UNDO button, app adds last removed entry.

What’s worth mentioning is that in order to get Scaffold inside build function (before it’s actually built) I used Future’s delayed method with duration equal to 0.

And that’s it! 🙂

If you are interested in full code, I encourage you to check out my repository. 🙂

2 thoughts on “Deleting entry and undoing deletion in snackbar – Flutter – WeightTracker 7

  1. hello there.

    i have issue when i try compile your code.

    can you help me?

    thank you

    compiler message: lib/screens/main.dart:13:33: Error: The argument type ‘(#lib1::ReduxState, dynamic) → #lib1::ReduxState’ can’t be assigned to the parameter type ‘(dynamic, dynamic) → dynamic’.
    compiler message: Try changing the type of the parameter, or casting the argument to ‘(dynamic, dynamic) → dynamic’.
    compiler message: final Store store = new Store(stateReducer,
    compiler message: ^
    Compiler failed on /Users/mac/Downloads/weight_tracker/lib/screens/main.dart
    Error launching application on iPhone XS Max.

Leave a Reply

Your email address will not be published.