Flutter, add an undo feature

Introduction

Humans make mistakes all the time.

In this article, we are going to see how we can avoid unwanted regular actions that have an impact on an external system state.

Use case

For example, imagine you are writing a super important email for a job interview. Unfortunately, you accidentally press the send button, and you send a dummy email to your future boss. Now you have to write a new email that explains why you have just sent this strange email. This is not really the perfect scenario for your future job interview.

What can we do?

In this case, it will be impossible to add an alert dialog. The goal of the app is to view and send emails. If each time you send an email you have to go through a dialog that prevents a mistake that is occurring at most 1% of the time, it will be pretty annoying.

Also, you can't add a Ctrl+z feature. When the email is sent, it's out of the app’s control.

You might implement logic on the server side. You can push emails into a queue. Then emails are sent after a safety delay. And during this delay, the email app can tell the server "please stop this email sending task, it was a mistake."

This can totally work, but I see three drawbacks to this solution.

First, it requires that you have total control of the server.

Second, if you have total control on the server. Implementing this feature can take a significant amount of time and effort.

Third, it's dependent on network calls.

Even if it has drawbacks, I think this solution is the best in most cases

In this article, we are going to see another way. Basically, we are going to set the safety delay inside the app. It might not be the perfect solution (it depends on each case), but it's a pretty straightforward solution, easily implemented.

Cancelable to the rescue

There is a great package named async. It adds some great features on asynchronous operation.

We are going to use an object from this library named CancelableOperation. It's an object that wraps a future, and it can cancel the future operation programmatically.

It works like this:

final cancelableOperation = CancelableOperation.fromFuture(
      your future function here,
)

What we can do now is pass a Future.delay with the desired safety delay. Then, we chain the cancelable operation with the action we want to do (for example, send an email). We can also create a function which will cancel our operation

Like this:

const duration = Duration(seconds: 2);

final cancelableOperation = CancelableOperation.fromFuture(
      Future.delayed(duration),
    ).then(
      (_) => your action,
);

void undoFunction() {
      cancelableOperation.cancel();
}

Now we can pass the undoFunction to a button on a snack bar for example. So if the user makes a mistake, he can still undo it.

Final code with demo

import "package:async/async.dart";
import 'package:flutter/material.dart';

const Color darkBlue = Color.fromARGB(255, 18, 32, 47);

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(
        scaffoldBackgroundColor: darkBlue,
      ),
      home: const Scaffold(
        body: Center(
          child: MyWidget(),
        ),
      ),
    );
  }
}

class MyWidget extends StatefulWidget {
  const MyWidget({Key? key}) : super(key: key);

  @override
  State<StatefulWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  final _items = <String>[];

  // HERE 
  void _triggerActionWithAnUndoOption(BuildContext context) {
    const duration = Duration(seconds: 2);

    final cancelableOperation = CancelableOperation.fromFuture(
      Future.delayed(duration),
    ).then(
      (_) => _addItem("My action is done"),
    );

    void undoFunction() {
      cancelableOperation.cancel();
      _addItem("My action is canceled");
    }

    _showSnackbar(
      context: context,
      undoFunction: undoFunction,
      duration: duration,
    );
  }

  void _addItem(String item) {
    setState(() {
      _items.add(item);
    });
  }

  void _showSnackbar(
      {required BuildContext context,
      required void Function() undoFunction,
      required Duration duration}) {
    final snackBar = SnackBar(
      content: const Text('Ok, you can still undo this'),
      duration: duration,
      action: SnackBarAction(
        label: 'UNDO',
        onPressed: undoFunction,
      ),
    );

    // Find the ScaffoldMessenger in the widget tree
    // and use it to show a SnackBar.
    ScaffoldMessenger.of(context).showSnackBar(snackBar);
  }

  @override
  Widget build(BuildContext context) {
    return ListView(children: [
      TextButton(
          child: const Text("Click me :)"),
          onPressed: () => _triggerActionWithAnUndoOption(context)),
      ..._items.map<Widget>((e) => Center(child: Text(e))),
    ]);
  }
}

undà-demo-v2.gif

Drawback

This is great. The user can undo his action if he wants, without being annoyed.

A major drawback is that if the user cleans the app resources while the action is still in the safety delay, the action will not be executed.

So if the user does that

undo-demo-remove-v2.gif

Our action might not be executed.

This is pretty logical, because we clean all resources and our desired action is still waiting to be executed.

What can we do?

We can still make it work on the app side.

For this, we will have to execute our action with a safety delay in the background.

There is a great package for this named workmanager

Only works on Android and iOS

The action will be executed in the background. That means that it will not be able to access the app resources. You can't pass any object instance from your app. The action must be static.

First you must set up your app, for this check the documentation for the Android platform and for the iOS platform

It works like this.

First, we register a callback that is going to execute different functions based on a task name.

void callbackDispatcher() {
  Workmanager().executeTask((task, inputData) {
    print("Native called background task: $task"); //simpleTask will be emitted here.
    return Future.value(true);
  });
}

This must be at the top level of the app, or a static method of a class

We are going to create a service who are going to do three things.

  • register the callbackDispatcher
  • Send task on the background
  • Cancel task
import 'package:workmanager/workmanager.dart';

void callbackDispatcher() {
  BackgroundService.workmanager.executeTask((task, inputData) {
    print("Native called background task:"); //simpleTask will be emitted here.
    return Future.value(true);
  });
}

class BackgroundService {
  static final Workmanager workmanager = Workmanager();
  static const String taskName = "UndoDemo";

  static Future<void> initialize() {
    try {
      return workmanager.initialize(callbackDispatcher, isInDebugMode: true);
    } on Exception catch (e) {
      print(e);
      rethrow;
    }
  }

  static Future<void> executeTaskInBackground(
      {required String uniqueName, Duration duration = Duration.zero}) async {
    return workmanager.registerOneOffTask(uniqueName, taskName,
        initialDelay: duration);
  }

  static Future<void> cancelTask(String uniqueName) {
    return workmanager.cancelByUniqueName(uniqueName);
  }
}

We use static method, this is great for our simple use case. One drawback of this is that static method are hard to mock, meaning they are hard to use in test.

First, you must update the main method to initialize the BackgroundService

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await BackgroundService.initialize();
  runApp(const MyApp());
}

Now we can update the function trigger by our button to make it like this

  void _triggerActionWithAnUndoOption(BuildContext context) {
    const duration = Duration(seconds: 2);

    const taskUniqueName = "simpleTask";

    BackgroundService.executeTaskInBackground(
        uniqueName: taskUniqueName, duration: duration);

    void undoFunction() {
      BackgroundService.cancelTask(taskUniqueName);
      _addItem("My action is canceled");
    }

    _showSnackbar(
      context: context,
      undoFunction: undoFunction,
      duration: duration,
    );
  }

Now we have the same workflow as before, but our actions are executed in the background. So even if our user clears the app process before our action is executed, it will still be executed (in the background).

undo-workmanager-demo.gif

Note the debug notification who tell us that our background task was successful

Conclusion

Our solution was pretty simple and straightforward.

Now our user can make errors without much consequence. We learn what was a CancelableOperation and that we can execute tasks in the background with workmanager.