Before this tutorial you should have set up your development environment with How to Install Flutter on Mac OS and ran your first application with Your First Flutter Application.

Before starting double check that the SDK and simulators/emulators are correctly installed and setup by running flutter doctor.

Entries in the serie:

Notice: This tutorial is very long. Make sure you have the time to follow it throughly. It works best if you follow every step on your own and actually type the code in the editor instead of copying. And in the end you might even want to add a feature or two to the application!

Here’s a look at what you’re going to make following this tutorial:1

For this Tutorial we are going to use Visual Studio Code. You can do everything also do with Android Studio or other editors if you prefer them instead. Whenever we use a command palette command in Visual Studio Code you can use the Terminal and navigate to your project directory to run the same command.

Open VSCode, and invoke the command palette (by pressing ⌘ ⇧ P). Type “Flutter New Project” to create your new Flutter project. VSCode will let you select a folder where you want to save the generated folder.

After VSCode has finished creating the project it will automatically open it. Open the “pubspec.yaml” file from the list and find the “dependencies” in the list. Under them add “zefyr” as the last dependency like this:

dependencies:
  flutter:
    sdk: flutter

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^0.1.2
  zefyr:
    git:
      url: https://github.com/memspace/zefyr.git
      path: packages/zefyr
      ref: flutter-master

Save the file. Notice how Flutter is automatically downloaded and included the package upon save.

You might have noticed that we’re not only adding the name of the dependency, but also more instructions alongside it like the url of the repository and more. This is needed because we don’t want to download the package published on Pub (the tool to manage dependencies in Dart), but we want to always get the latest version even before it gets published on Pub since there are some fixes that we need that still haven’t made its way to Pub.

Zefyr provides a fully featured rich text editor with markdown support. You can take a look at their Github page for more information.

We can now show the default text editor provided by Zefyr on screen when we launch the application.

Open “main.dart” (from inside the “lib” folder) and change its content to:

import 'package:flutter/material.dart';
import 'full_page.dart'; //1

void main() {
  runApp(new MDEditorApp()); //2
}

class MDEditorApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Zefyr Editor',
      theme: ThemeData(primarySwatch: Colors.cyan),
      home: HomePage(),
      routes: {"/fullPage": buildFullPage}, //3
    );
  }

  Widget buildFullPage(BuildContext context) {
    //4
    return FullPageEditorScreen();
  }
}

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final nav = Navigator.of(context);
    return Scaffold(
      appBar: AppBar(
        elevation: 1.0,
        backgroundColor: Colors.grey.shade200,
        brightness: Brightness.light,
        title: ZefyrLogo(),
      ),
      body: Column(
        children: <Widget>[
          Expanded(child: Container()),
          FlatButton(
            //5
            onPressed: () => nav.pushNamed('/fullPage'),
            child: Text('Full page editor'),
            color: Colors.lightBlue,
            textColor: Colors.white,
          ),
          Expanded(child: Container()),
        ],
      ),
    );
  }
}
  1. We import the full page editor, we are going to create this file soon.
  2. The app will start by running the main function. Here we initialize the application and tell Flutter to run the class called MDEditorApp.
  3. We initialize the routes for the application. We are going to use it to navigate from the first screen to the editor screen. We need to define a string and an associated function (4) that returns the widget that corresponds to the new page.
  4. We create a button that when tapped will redirect us to the route “fullPage” that we defined above.

flmdedit1

We can now create the file “full_page.dart” that will include the editor.

import 'dart:async';
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:quill_delta/quill_delta.dart';
import 'package:zefyr/zefyr.dart';

class ZefyrLogo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        Text('MDEditor'),
      ],
    );
  }
}

class FullPageEditorScreen extends StatefulWidget {
  @override
  _FullPageEditorScreenState createState() => new _FullPageEditorScreenState();
}

final doc =
    r'[{"insert":"Zefyr"},{"insert":"\n","attributes":{"heading":1}},{"insert":"Soft and gentle rich text editing for Flutter applications.","attributes":{"i":true}},{"insert":"\n"},{"insert":"​","attributes":{"embed":{"type":"image","source":"asset://assets/breeze.jpg"}}},{"insert":"\n"},{"insert":"Photo by Hiroyuki Takeda.","attributes":{"i":true}},{"insert":"\nZefyr is currently in "},{"insert":"early preview","attributes":{"b":true}},{"insert":". If you have a feature request or found a bug, please file it at the "},{"insert":"issue tracker","attributes":{"a":"https://github.com/memspace/zefyr/issues"}},{"insert":'
    r'".\nDocumentation"},{"insert":"\n","attributes":{"heading":3}},{"insert":"Quick Start","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/quick_start.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Data Format and Document Model","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/data_and_document.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Style Attributes","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/attr'
    r'ibutes.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Heuristic Rules","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/heuristics.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"FAQ","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/faq.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Clean and modern look"},{"insert":"\n","attributes":{"heading":2}},{"insert":"Zefyr’s rich text editor is built with simplicity and fle'
    r'xibility in mind. It provides clean interface for distraction-free editing. Think Medium.com-like experience.\nMarkdown inspired semantics"},{"insert":"\n","attributes":{"heading":2}},{"insert":"Ever needed to have a heading line inside of a quote block, like this:\nI’m a Markdown heading"},{"insert":"\n","attributes":{"block":"quote","heading":3}},{"insert":"And I’m a regular paragraph"},{"insert":"\n","attributes":{"block":"quote"}},{"insert":"Code blocks"},{"insert":"\n","attributes":{"headin'
    r'g":2}},{"insert":"Of course:\nimport ‘package:flutter/material.dart’;"},{"insert":"\n","attributes":{"block":"code"}},{"insert":"import ‘package:zefyr/zefyr.dart’;"},{"insert":"\n\n","attributes":{"block":"code"}},{"insert":"void main() {"},{"insert":"\n","attributes":{"block":"code"}},{"insert":" runApp(MyWAD());"},{"insert":"\n","attributes":{"block":"code"}},{"insert":"}"},{"insert":"\n","attributes":{"block":"code"}},{"insert":"\n\n\n"}]';

Delta getDelta() {
  return Delta.fromJson(json.decode(doc) as List);
}

class _FullPageEditorScreenState extends State<FullPageEditorScreen> {
  final ZefyrController _controller =
      ZefyrController(NotusDocument.fromDelta(getDelta()));
  final FocusNode _focusNode = new FocusNode();
  bool _editing = false;
  StreamSubscription<NotusChange> _sub;

  @override
  void initState() {
    super.initState();
    _sub = _controller.document.changes.listen((change) {
      print('${change.source}: ${change.change}');
    });
  }

  @override
  void dispose() {
    _sub.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final theme = new ZefyrThemeData(
      cursorColor: Colors.blue,
      toolbarTheme: ZefyrToolbarTheme.fallback(context).copyWith(
        color: Colors.grey.shade800,
        toggleColor: Colors.grey.shade900,
        iconColor: Colors.white,
        disabledIconColor: Colors.grey.shade500,
      ),
    );

    final done = _editing
        ? [new FlatButton(onPressed: _stopEditing, child: Text('DONE'))]
        : [new FlatButton(onPressed: _startEditing, child: Text('EDIT'))];
    return Scaffold(
      resizeToAvoidBottomPadding: true,
      appBar: AppBar(
        elevation: 1.0,
        backgroundColor: Colors.grey.shade200,
        brightness: Brightness.light,
        title: ZefyrLogo(),
        actions: done,
      ),
      body: ZefyrScaffold(
        child: ZefyrTheme(
          data: theme,
          child: ZefyrEditor(
            controller: _controller,
            focusNode: _focusNode,
            enabled: _editing,
            imageDelegate: new CustomImageDelegate(),
          ),
        ),
      ),
    );
  }

  void _startEditing() {
    setState(() {
      _editing = true;
    });
  }

  void _stopEditing() {
    setState(() {
      _editing = false;
    });
  }
}

/// Custom image delegate used by this example to load image from application
/// assets.
///
/// Default image delegate only supports [FileImage]s.
class CustomImageDelegate extends ZefyrDefaultImageDelegate {
  @override
  Widget buildImage(BuildContext context, String imageSource) {
    // We use custom "asset" scheme to distinguish asset images from other files.
    if (imageSource.startsWith('asset://')) {
      final asset = new AssetImage(imageSource.replaceFirst('asset://', ''));
      return new Image(image: asset);
    } else {
      return super.buildImage(context, imageSource);
    }
  }
}
  1. This will be the content of our document (in JSON format). Documents in Zefyr are list of changes. We start from a blank slate and we insert and remove words. The advantage is that we already have a robust undo and redo functionality already implemented as baseline. Full documentation
  2. We initialize the document with the json file.
  3. We create a ZefyrController that includes the document that we just generated.
  4. We can also create a subscription that will notify us of any changes to the document. In this case in (5) we use this subscription to print each change to the console.
  5. When we destroy the widget we need to remember to also destroy the observer.
  6. Finally we initialize the editor with our controller. We also set an image delegate to be able to display inline images.

flmdedit2

Before running the application we need to add the image. The json of the text includes a custom image that needs to be placed in the “/assets/” folder. Create the folder and add the breeze.jpg image from here. We also need

Go to the debug window (or press ⌘4) and add Flutter: Press on the cog to add Flutter as debug by typing Flutter in the field and saving the autogenerated file. From now on we can just press the run button to run the app.

Try editing the text and adding custom styles.


That was just a preview of what the editor is capable of. Now it is time to build the whole app on top of it.

First let’s refactor the code into folders: flmdedit3

Leave out only the “main.dart” file. The rest goes into the view folder.

Model

Go in the model folder next and create a new file to store the model for our notes. Call it “note.dart”:

import 'package:meta/meta.dart';

class Note {
  Note({
    @required this.title,
    @required this.text,
    @required this.date,
  });

  final String title;
  String text;
  final DateTime date;
}

Our note will simply contains the title, the text and the creation date. Since we are going to load the data from a json file we can take care here of deserializing the json and creating the note objects from it.

First go back to the base folder of the application and edit “pubspec.yaml”. Under “flutter:” add

  assets:
    - assets/

This tells flutter to bundle everything inside the assets folder and copy it inside the application.

JSON Input

Initially we’re just going to load a (static and local) json file. Eventually we can take the data from a remote server to have it synced and editable.

Create the “text.json” file in the “assets” folder and paste:

{"results":[{"title":"A","date":"2019-01-01 11:12:13","text":[{"insert":"Zefyr"},{"insert":"\n","attributes":{"heading":1}},{"insert":"Soft and gentle rich text editing for Flutter applications.","attributes":{"i":true}},{"insert":"\n"},{"insert":"​","attributes":{"embed":{"type":"image","source":"asset://assets/breeze.jpg"}}},{"insert":"\n"},{"insert":"Photo by Hiroyuki Takeda.","attributes":{"i":true}},{"insert":"\nZefyr is currently in "},{"insert":"early preview","attributes":{"b":true}},{"insert":". If you have a feature request or found a bug, please file it at the "},{"insert":"issue tracker","attributes":{"a":"https://github.com/memspace/zefyr/issues"}},{"insert":".\nDocumentation"},{"insert":"\n","attributes":{"heading":3}},{"insert":"Quick Start","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/quick_start.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Data Format and Document Model","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/data_and_document.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Style Attributes","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/attributes.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Heuristic Rules","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/heuristics.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"FAQ","attributes":{"a":"https://github.com/memspace/zefyr/blob/master/doc/faq.md"}},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Clean and modern look"},{"insert":"\n","attributes":{"heading":2}},{"insert":"Zefyr’s rich text editor is built with simplicity and flexibility in mind. It provides clean interface for distraction-free editing. Think Medium.com-like experience.\nMarkdown inspired semantics"},{"insert":"\n","attributes":{"heading":2}},{"insert":"Ever needed to have a heading line inside of a quote block, like this:\nI’m a Markdown heading"},{"insert":"\n","attributes":{"block":"quote","heading":3}},{"insert":"And I’m a regular paragraph"},{"insert":"\n","attributes":{"block":"quote"}},{"insert":"Code blocks"},{"insert":"\n","attributes":{"heading":2}},{"insert":"Of course:\nimport ‘package:flutter/material.dart’;"},{"insert":"\n","attributes":{"block":"code"}},{"insert":"import ‘package:zefyr/zefyr.dart’;"},{"insert":"\n\n","attributes":{"block":"code"}},{"insert":"void main() {"},{"insert":"\n","attributes":{"block":"code"}},{"insert":" runApp(MyWAD());"},{"insert":"\n","attributes":{"block":"code"}},{"insert":"}"},{"insert":"\n","attributes":{"block":"code"}},{"insert":"\n\n\n"}]},{"title":"B","date":"2019-02-01 11:12:13","text":[{"insert":"Zefyr2"},{"insert":"\n\n\n"}]}]}

Go back to the “pubspec.yaml” and add, under the lines to import zefyr making sure to align the text:

  intl: ^0.15.7

“intl” allows us to format dates into a human readable format.

Open “note.dart” in the model folder and add:

  static List<Note> allFromResponse(String response) {//1
    var decodedJson = json.decode(response).cast<String, dynamic>();//2

    return decodedJson['results']
        .cast<Map<String, dynamic>>()
        .map((obj) => Note.fromMap(obj))//3
        .toList()
        .cast<Note>();//4
  }

  static Note fromMap(Map map) {
    var textJson = json.encode(map['text']);//5
    return new Note(//6
      title: map['title'],
      text: textJson,
      date: DateTime.parse(map['date']),//7
    );
  }
  1. We define a function to get all the notes from the json
  2. We decode the json from a string to an object and we convert it to the right format to be parsed
  3. Since the notes are inside of a node called ‘results’ we only take the results and we call the function fromMap on it
  4. Finally we try to cast the resulting object into an array of notes
  5. Zefyr requires that the text is in json so we convert the text object back into json and we store the string of the json (just the text) in the text variable.
  6. We create a new note taking the “title” from the json object
  7. For the date we take the string from the json and convert it into a DateTime object that we can later format as we like for display

List Page

Next we are going to create the page that will show the list of our notes.

Go in “view” and create a new file called “list_page.dart”. Add the imports:

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';//date formatter
import 'full_page.dart';//detail page
import '../model/note.dart';//note model

Under, define the widget:

class NotesListPage extends StatefulWidget {
  @override
  _NotesListPageState createState() => new _NotesListPageState();
}

class _NotesListPageState extends State<NotesListPage> {
  List<Note> _notes = [];
  final formatter = new DateFormat('yyyy-MM-dd hh:mm:ss');

  @override
  void initState() {
    super.initState();
    _loadNotes().then((onValue) {
      setState(() {
        _notes = onValue;
      });
    }).catchError(print);
  }

Note that we initialize the formatter to show the date as “yyyy-MM-dd hh:mm:ss”. More Information initState is called to initialize the widget. Here we want to populate the now empty _notes array with the notes taken from the json file. Define the _loadNotes function to do just that:

  Future<void> _loadNotes() async {
    final jsonResponse =
        await DefaultAssetBundle.of(context).loadString("assets/text.json");

    setState(() {
      _notes = Note.allFromResponse(jsonResponse);
    });
  }

First we load the file “assets/text.json” that contains our notes, after we set the state to our updated notes object. Note that the fuction returns a Future. Futeres are used by dart to run asyncronous code so that the IO operation of loading the json file from disk doesn’t freeze the application. Note that you also need to mark the function as async and import the async library using “import ‘dart:async’;“. Put “await” infront of the function to execute asyncrounously (it is really similar to C# and js async/await).

Now for each note we create a ListTile containing the tile and the date and when tapped we open the detail of that note:

Widget _buildNoteListTile(BuildContext context, int index) {
    var note = _notes[index];

    return new ListTile(
      onTap: () => _navigateToNoteDetails(note, index),
      title: new Text(note.title),
      subtitle: new Text(formatter.format(note.date)),
    );
  }

   void _navigateToNoteDetails(Note note, Object index) {
    Navigator.of(context).push(
      new MaterialPageRoute(
        builder: (c) {
          return new FullPageEditorScreen(note);//1
        },
      ),
    );
  }
  1. FullPageEditorScreen will be the detail view.

Finally we build the widget:

  @override
  Widget build(BuildContext context) {
    Widget content;

    if (_notes.isEmpty) {
      content = new Center(
        child: new CircularProgressIndicator(),//1
      );
    } else {
      content = new ListView.builder(//2
        itemCount: _notes.length,
        itemBuilder: _buildNoteListTile,
      );
    }

    return new Scaffold(
      appBar: new AppBar(title: new Text('MDEDitor')),
      body: content,
    );
  }
}
  1. If the list of notes is empty we show a loading spinner and wait fore the data to be loaded.
  2. If it’s not empty we create the ListView using the function that we defined above.

flmdedit4

Detail Page

The list page is done. The last page to do is the detail page. We already have it, but it needs some small tweaks to load the note from what the previous screen passes in and not from the json string in the code.

At the top of the page import our model “import ‘../model/note.dart’;“. In the class “FullPageEditorScreen” add the note:

class FullPageEditorScreen extends StatefulWidget {
  FullPageEditorScreen(this.note);

  final Note note;

  @override
  _FullPageEditorScreenState createState() => new _FullPageEditorScreenState();
}

Delete the Delta function and the json string from the file since we won’t be needing them anymore.

Edit ”_FullPageEditorScreenState”:

class _FullPageEditorScreenState extends State<FullPageEditorScreen> {
  ZefyrController _controller;
  final FocusNode _focusNode = new FocusNode();
  bool _editing = false;
  StreamSubscription<NotusChange> _sub;

  @override
  void initState() {
    super.initState();
    _controller = ZefyrController(NotusDocument.fromDelta(
        Delta.fromJson(json.decode(widget.note.text) as List)));//1
    _sub = _controller.document.changes.listen((change) {
      print('${change.source}: ${change.change}');
    });
  }
  1. We use the content of note.text to use as the text file to display

flmdedit5

We can now tell flutter that at the start of the application it should load our NotesListPage widget. Go to “main.dart” and change its contents to:

import 'package:flutter/material.dart';
import 'view/list_page.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      theme: new ThemeData(
        primarySwatch: Colors.blue,
        accentColor: const Color(0xFFF850DD),
      ),
      home: new NotesListPage(),
    );
  }
}

Run the application and navigate throught the 2 notes that we added in the json file.

Add and Edit

It’s now to time not to use our static notes in code, but to be able to edit them as well. Let’s start by adding an Add button in the list to be able to add a new note. The button will show the full_page widget on screen without any text that we can edit freely.

Go to “list_page.dart” and when you set the Scaffold change the AppBar to:

return new Scaffold(
      appBar: new AppBar(
        title: new Text('MDEDitor'),
        actions: add,
      ),
      body: content,
    );

We did not set the “add” action yet. Let’s do it now. Right before returning the Scaffold create the button:

    final add = [new FlatButton(onPressed: _addNote, child: Text('ADD'))];
    return new Scaffold(
      appBar: new AppBar(
        title: new Text('MDEDitor'),
        actions: add,
      ),
      body: content,
    );

We only have one button, but we need to pass a list of actions to “actions” so we wrap it into a List (same as an array). We can now create the ”_addNote” function. Add it right under:

return new Scaffold(
      appBar: new AppBar(
        title: new Text('MDEDitor'),
        actions: add,
      ),
      body: content,
    );
  }

  void _addNote() {
    _navigateToNoteDetails(null, null);
  }
}

Note that you never had to stop the emulator and everything works. Even adding a new widget on screen that is triggered from the push of a button! If you click on “Add” now though it is not going to work with the following error: “The getter ‘text’ was called on null.”

The widget was designed with the idea of always have a note to start up with while we never passed a note now. We have two options: pass the note null and let the widget accept null as a valid value or create a new note before navigating to the widget. There is not a correct answer imo (you’re free to disagree) so pick whatever you prefer. I’m going to create a new note before passing it to the widget (more straightforward).

Edit ”_addNote” to create a new note and pass it to the controller via Dependency Injection 2:

  void _addNote() {
    final note = new Note(title: "", text: "", date: new DateTime.now());
    _navigateToNoteDetails(note, null);
  }

That’s not fine either though. Text is not a string in zephyr, it is a JSON file with all the changes that occurred to the file. To create a new file we should add the realtive JSON as well instead on an empty text. Edit ”_addNote”:

void _addNote() {
    final note =
        new Note(title: "", text: Note.emptyText, date: new DateTime.now());
    _navigateToNoteDetails(note, null);
  }

Go to “note.dart” and add the implementation for emptyText:

    static final String emptyText = '[{"insert":"\\n"}]';//be careful to use ' and not "

Click on “Add” and a new empty page will open. The problem is that the keyboard doesn’t automatically slide up since it’s the same screen used to edit a text and we might not want to cover half the screen if we want to take a look at an older note.

To change it we are going to pass an additional parameter to the widget to tell it if it should slide up the keyboard or not.

Edit ”_addNote” to add the parameter while calling ”_navigateToNoteDetails”:

void _addNote() {
    final note =
        new Note(title: "", text: Note.emptyText, date: new DateTime.now());
    _navigateToNoteDetails(note, null);
  }

Edit ”_navigateToNoteDetails” to add it there as well:

  void _navigateToNoteDetails(Note note, Object index,
      {bool startEditing = false}) {
    debugPrint(note.text);
    Navigator.of(context).push(
      new MaterialPageRoute(
        builder: (c) {
          return new FullPageEditorScreen(note, startEditing);
        },
      ),
    );
  }

Note that we are declaring a parameter with a default value. That’s why we are wrapping “startEditing” into curly ”{}” braces.

Click on the Add button, a new note will be created, but you still need to press edit to open the keyboard. Let’s change that.

First edit ”_addNote”,at the bottom change ”_navigateToNoteDetails” to pass startEditing to true:

    _navigateToNoteDetails(note, null, startEditing: true);

Now onto the “full_page.dart” widget to automatically open the editing view. Edit “FullPageEditorScreen” to add the bool:

class FullPageEditorScreen extends StatefulWidget {
  FullPageEditorScreen(this.note, this.openOnEditing);

  final Note note;
  final bool openOnEditing;

  @override
  _FullPageEditorScreenState createState() => new _FullPageEditorScreenState();
}

In the initializer check the value of “openOnEditing” and if set to true slide the keyboard up (aka start editing):

@override
  void initState() {
    super.initState();
    _controller = ZefyrController(NotusDocument.fromDelta(
        Delta.fromJson(json.decode(widget.note.text) as List)));
    _sub = _controller.document.changes.listen((change) {
      print('${change.source}: ${change.change}');
    });
    if (widget.openOnEditing) {
      _startEditing();
    }
  }

We have now an application that can open, edit and add new notes. It still doesn’t save anything that we write though. When we are finished and we quit a screen it just discards everything that we’ve done though. The next obvious step is to add persistance.

Data Persistance

We are going to add a few dependencies to the “pubspec.yaml” file. Add them right under “intl”:

  path_provider:
  shared_preferences:
  path:

“pathprovider” and “path” make it easier to work with system paths across different OSes, “sharedpreferences” does the same for preferences.

Initially we are going to store everything in json files. In the future it would be nice to store the metadata in the database(such as title, creation date, last modified date, …).

In “libs” create a new folder called Persistance and add file_manager.dart

import 'dart:io';
import 'dart:async';

import 'package:intl/intl.dart'; //date
import 'package:markdowneditor/model/note.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'package:flutter/foundation.dart'; //log

class FileManager {
  static final FileManager _singleton = new FileManager._internal();

  factory FileManager() {
    return _singleton;
  }

  FileManager._internal();

  Future<String> get _localPath async {
    final directory = await getApplicationDocumentsDirectory().toString();
    return p.join(directory, "notes"); //path takes strings and not Path objects
  }
}

Here we declare a singleton, a file that can only be initialized once and that can be globally accessed by our application whenever needed. For now we only declare one method to get the location of the folder where we are going to save all of our notes. Later on we are going to also add methods to save and retrieve notes.

Go back to note.dart and add the id field. It’s going to be useful to uniquely identify a note. If we used the title we would have needed each and every title to be unique, but we might want to have the same title for different notes.

class Note {
  Note({
    @required this.id,
    @required this.title,
    @required this.text,
    @required this.date,
  });


  ...


  final int id;
  final String title;
  String text;
  final DateTime date;

First declare it and require it in the initialization code (we can’t have a null id). After read the json value for id and set the object with the value in the JSON.

 static Note fromMap(Map map) {
    print(map['text']);
    var textJson = json.encode(map['text']);
    return new Note(
      id: map['id'],
      title: map['title'],
      text: textJson,
      date: DateTime.parse(map['date']),
    );
  }

Go back to xx and add the code to write a note:

  Future<File> writeNote(Note note) async {
    var file = await _localPath;
    file = p.join(
        file,
        (note.title == ""
            ? DateFormat('kk:mm:ssyMMdd').format(DateTime.now())
            : note.title)); //add timestamp to title
    // Write the file


    final noteJson = note.toJson();
    return File(file).writeAsString('$noteJson');

  }

Finally edit the function “fromMap” to remove the jsonEncoding since we already save to and from valid json object:

  static Note fromMap(Map map) {
    // print(map['text']);
    // var textJson = json.encode(map['text']);
    return new Note(
      id: map['id'],
      title: map['title'],
      text: map['text'], //textJson,
      date: DateTime.parse(map['date']),
    );
  }

Here we are declaring a future since the operation is asyncronous and it might take sometime. We get the path of the ntes folder and we append the filename. To avoid conflixrs we add the current timestamp to the title of the note.

Retrieving it is more difficult since Android requires esplicit permissions rto read from the filesystem. To be granted those premissions we need to import the xx package to request it. Go back to the pub spec.yaml file and after all the imports add “simple_permissions:” and save. Flutter will automatically impoert the package and oyo ca start using it right after the download has completed. Go back to xx.dart and import the package

import 'package:simple_permissions/simple_permissions.dart';

we are going to need permission both to read from the external storage as well as rto write on it. First go to your android application folder and add the permissions to the Android Manifest, we are not going to need any particular permission for iOS since we are going to write in the internal application storage. Go to androi > app > src > main > AndroidManifest.xml and edit:

    <!-- The INTERNET permission is required for development. Specifically,
         flutter needs it to communicate with the running application
         to allow setting breakpoints, to provide hot reload, etc.
    -->
    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /><!-- add -->
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /><!-- add -->

Now we can edit our persistance code to add the new permission request as well. Go to file_manager.dart and import ‘simple permissions’:

import 'package:simple_permissions/simple_permissions.dart'; //OS permissions

Edit the writeNote function to request permissions:

  Future<File> writeNote(Note note) async {
    var file = await _localPath;
    file = p.join(
        file,
        (note.title == ""
            ? DateFormat('kk:mm:ssyMMdd').format(DateTime.now())
            : note.title)); //add timestamp to title
    // Write the file

     return SimplePermissions.requestPermission(Permission.WriteExternalStorage)
         .then((value) {
       if (value == PermissionStatus.authorized) {
    final noteJson = note.toJson();
    return File(file).writeAsString('$noteJson');
       } else {
         SimplePermissions.openSettings();
         return null;
       }
     });
  }

And finally add a function to get the saved notes below:

 Future<List<Note>> getNotes() async {
    //need file access permission on android. use https://pub.dartlang.org/packages/simple_permissions#-example-tab-
    final file = await _localPath;

    return SimplePermissions.requestPermission(Permission.ReadExternalStorage)
        .then((value) {
      if (value == PermissionStatus.authorized) {
        try {
          var result = Directory(file)
              .list(recursive: false, followLinks: false)
              .asyncMap((fse) {
                if (fse is File) return fse.readAsString();
              })
              .asyncMap((s) => Note.fromJsonResponse(s))
              .toList();
          return result;
        } catch (e) {
          debugPrint('getNoteError: $e');
          // If encountering an error, return 0
          return null;
        }
      } else {
        SimplePermissions.openSettings();
        return null;
      }
    });
  }

Here we first ask for permissions, like above and return null if we don’t have sufficient permissions. If we do we scan the directory and get back every file that we save. We parse each file and convert it into a Note object and we return the parsed Note objects. Notice that we load the Notes calling the fromJsonResponse method that we haven’t created yet. Go back to note.dart and add the fromJsonResponse static function to the class:

  static Note fromJsonResponse(String response) {
    var decodedJson = json.decode(response);
    var casted = decodedJson.cast<String, dynamic>();
    return fromMap(casted);
  }

Before we import the notes from the file system we need to make sure the “notes” directory we’re using to save the notes actuslly exists. We can create the folder if it doesn’t exist each time we try to retrieve the directory path so we are sure that we always create it before accessing it. Since we’re only doing it once we can do it syncronously:

  Future<String> get _localPath async {
    final directory = (await getApplicationDocumentsDirectory()).path;
    final finalPath =
        p.join(directory, "notes"); //path takes strings and not Path objects
    Directory(finalPath)
        .createSync(recursive: true); //create directory if non existant
    return finalPath;
  }

Here we also use .path instead of .toString() since toString returns a string description of the directory, but we just need the path.

Now we need to go back to the list_page where we load the Notes and instead of loading from the mock json file load them from the filesystem. At the top of the file add the import for the persistance manager:

import '../persistance/file_manager.dart';

And when we save our note also persist it to disk. Edit the stopEditing function to save the json to disk:

  void _stopEditing() {
    final jsonValue = jsonEncode(_controller.document.toJson());
    var noteCopy = widget.note;
    noteCopy.text = jsonValue;
    fm.writeNote(noteCopy).then((res) {
      print('ok');
      print(res);
    });
    setState(() {
      _editing = false;
    });
  }
}

”_controller.document” will contain our updated note. We need to convert it to json and edit our note object with the updated text before saving it.

Also go to note.dart and add a method to convert the Note back to json. First import the date library at the top of the document since we’ll need to convert the creation date to a string.

import 'package:intl/intl.dart'; //date

After add the following method inside the Note class:

String toJson() {
    return json.encode({
      "id": this.id,
      "text": this.text,
      "title": this.title,
      "date": DateFormat('kk:mm:ssyMMdd').format(this.date)
    });
  }

To retrieve the note we can go back to the “list_page” and again import the FileManager using

import '../persistance/file_manager.dart';

WHen we initialize the json we can now take it from the filesystem:

 Future<void> _loadNotes() async {
    // http.Response response = await http.get('https://randomuser.me/api/?results=25');
    var fm = new FileManager();
    final jsonResponse = await fm.getNotes();
    // await DefaultAssetBundle.of(context).loadString("assets/text.json");

    setState(() {
      _notes = jsonResponse;
      // _notes = Note.allFromResponse(response.body);
    });
  }

Now you should be all set. Run the application

Note: For the iOS application change the deployment target to at least iOS 10 since simple_permissions requires it. You can do it by opening Xcode and changing the Deployment Target of the application from the project settings section.

Everything woprks as expected. The list shows the notes you have saved and you can add or edit notes. There are still 2 problems though:

  1. If you have no notes the spinning wheel never disappears
  2. If you add a new note you don’t see it in the list at first

For the first one there are two ways to accomplish it, either have a dummy note if you have no note to show how the app words or you could add a loaded boolean and check that on top of only checking if the list is empty. We are going to go with the dummy note since it’s good to have an idea on how the application will work when you first open it.

Go to list_page.dart and change the initState method:

 @override
  void initState() {
    super.initState();
    _loadNotes().then((onValue) {
      if (onValue.length == 0) {
        _loadFakeNote().then((onValue){
            _notes = onValue;
        }).catchError(print);
      } else {
        setState(() {
          _notes = onValue;
        });
      }
    }).catchError(print);
  }

Here we check if the result array is empty and if it is we add the fake note. We also need to add the _loadFakeNote method:

  Future<List<Note>> _loadFakeNote() async {
      var response = await DefaultAssetBundle.of(context).loadString("assets/text.json");
      var notes = Note.allFromResponse(response);
      return notes;
  }

Go to assets/text.json and edit the note with whatever you want to appear when the user has no notes.

To solve the second issue we need to know when we add a new note (when we press the save button). We can also reload the list when we come back to the main screen. It is better to do the latter in case we want to add some sort of sync feature in the future so the add button won’t be the only way to add a new note.

To do that we need to know when the screen becomes visible again. To do that we can use a “RouteObserver”. Create a new file called “route_observer.dart” under “lib” and add the global variable for our observer:

import 'package:flutter/material.dart';

// global RouteObserver
final RouteObserver<PageRoute> routeObserver = new RouteObserver<PageRoute>();

From “main.dart” we need to inject it into our application:

import 'route_observer.dart';


...


class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      navigatorObservers: <NavigatorObserver>[routeObserver],

And finally from the screen where we want to observe we need to register the callback. For now we only want to listen to the event from the list view so open “list_page.dart” and after importing the observer add:

import '../route_observer.dart';

...

class _NotesListPageState extends State<NotesListPage> with RouteAware {

First we make sure to mark the State as RouteAware, done that we rebuild the model each time we come back on screen:

// Called when the top route has been popped off, and the current route shows up.
  void didPopNext() {
    // debugPrint("didPopNext ${runtimeType}");
    //force reload page
    _loadNotes().then((onValue) {
      if (onValue.length == 0) {
        _loadFakeNote().then((onValue) {
          _notes = onValue;
        }).catchError(print);
      } else {
        setState(() {
          _notes = onValue;
        });
      }
    }).catchError(print);
  }

Finally we need to register to the callback and unregister if we get destoyed:

@override
  void didChangeDependencies() {
    super.didChangeDependencies();
    routeObserver.subscribe(this, ModalRoute.of(context));
  }

  @override
  void dispose() {
    routeObserver.unsubscribe(this);
    super.dispose();
  }

If you want to listen for more states you can implement the other methods, such as:

// // Called when the current route has been pushed.
  // void didPush() {
  //   debugPrint("didPush ${runtimeType}");
  // }

  // // Called when the current route has been popped off.
  // void didPop() {
  //   debugPrint("didPop ${runtimeType}");
  // }

  // // Called when a new route has been pushed, and the current route is no longer visible.
  // void didPushNext() {
  //   debugPrint("didPushNext ${runtimeType}");
  // }

We are not going to need them so we are going to leave them commented out at the moment.

Conclusion

You now have a fully featured minimal note taking app with no jargon that can create and edit markdown files persisting them on the local filesystem on both Android and iOS.

Bonus: Polishing Touches

The edit and save buttons are black while the rest of the application uses white as its primary color. Go in full_page.dart and change the color to white:

final done = _editing
        ? [
            new FlatButton(
                onPressed: _stopEditing,
                child: Text(
                  'DONE',
                  style: TextStyle(color: Colors.white),
                ))
          ]
        : [
            new FlatButton(
                onPressed: _startEditing,
                child: Text(
                  'EDIT',
                  style: TextStyle(color: Colors.white),
                ))
          ];

Do the same for the add button in list_page:

final add = [
      new FlatButton(
          onPressed: _addNote,
          child: Text(
            'ADD',
            style: TextStyle(color: Colors.white),
          ))
    ];

We can also sort the notes from their lastModified date since now they’re sorted by how the files are sorted in the filesystem. To do that add a lastModified field to our object that will get serialized to json:

class Note {
  //extends Model {
  Note(
      {@required this.id,
      @required this.title,
      @required this.text,
      @required this.date,
      @required this.lastModified
      });

  static final String emptyText = '[{"insert":"\\n"}]';

  final int id;
  String title;
  String text;
  final DateTime date;
  DateTime lastModified;



  ...


  static Note fromMap(Map map) {
    // print(map['text']);
    // var textJson = json.encode(map['text']);
    return new Note(
        id: map['id'],
        title: map['title'],
        text: map['text'], //textJson,
        date: DateTime.parse(map['date']),
        lastModified: DateTime.parse(map['lastModified']));
  }



  ....



  String toJson() {
    return json.encode({
      "id": this.id,
      "text": this.text,
      "title": this.title,
      "date": DateFormat('yyyy-MM-dd hh:mm:ss').format(this.date),
      "lastModified":
          DateFormat('yyyy-MM-dd hh:mm:ss').format(this.lastModified)
    });
  }

  void changeNoteText(String text) {
    this.lastModified = DateTime.now();
    this.text = text;
  }

In list_page add the new field to the new note that we create when we press on the add button:

  void _addNote() {
    final note = new Note(
        id: 0,
        title: "",
        text: Note.emptyText,
        date: new DateTime.now(),
        lastModified: new DateTime.now());
    _navigateToNoteDetails(note, null, startEditing: true);
  }

In full_page when we press save and we call changeNoteText we are also modifying the lastModified date. Take care though, modifying the Note object by adding the lastModified date is not retrocompatible so all your saved json files will now be invalid. You can delete the app and reinstall it to clear the data or create a function to automatically populate the last modified date once you find a file that doesn’t have it. Also make sure to edit the text.json file in assets with the new field: "lastModified": "2019-04-23 11:12:13",.

I also noticed that we haven’t touched the text.json file in a while. Meanwhile we have changed how we handle the json of the “text” property so change the json inside “text” to be escaped and inside a string. We also changed the json from being an array to being an object:

{"id":0,"title":"A","date":"2019-01-01 11:12:13","text":"[{\"insert\":\"Zefyr\"},{\"insert\":\"\\n\",\"attributes\":{\"heading\":1}},{\"insert\":\"Soft and gentle rich text editing for Flutter applications.\",\"attributes\":{\"i\":true}},{\"insert\":\"\\n\"},{\"insert\":\"\u200B\",\"attributes\":{\"embed\":{\"type\":\"image\",\"source\":\"asset:\/\/assets\/breeze.jpg\"}}},{\"insert\":\"\\n\"},{\"insert\":\"Photo by Hiroyuki Takeda.\",\"attributes\":{\"i\":true}},{\"insert\":\"\\nZefyr is currently in \"},{\"insert\":\"early preview\",\"attributes\":{\"b\":true}},{\"insert\":\". If you have a feature request or found a bug, please file it at the \"},{\"insert\":\"issue tracker\",\"attributes\":{\"a\":\"https:\/\/github.com\/memspace\/zefyr\/issues\"}},{\"insert\":\".\\nDocumentation\"},{\"insert\":\"\\n\",\"attributes\":{\"heading\":3}},{\"insert\":\"Quick Start\",\"attributes\":{\"a\":\"https:\/\/github.com\/memspace\/zefyr\/blob\/master\/doc\/quick_start.md\"}},{\"insert\":\"\\n\",\"attributes\":{\"block\":\"ul\"}},{\"insert\":\"Data Format and Document Model\",\"attributes\":{\"a\":\"https:\/\/github.com\/memspace\/zefyr\/blob\/master\/doc\/data_and_document.md\"}},{\"insert\":\"\\n\",\"attributes\":{\"block\":\"ul\"}},{\"insert\":\"Style Attributes\",\"attributes\":{\"a\":\"https:\/\/github.com\/memspace\/zefyr\/blob\/master\/doc\/attributes.md\"}},{\"insert\":\"\\n\",\"attributes\":{\"block\":\"ul\"}},{\"insert\":\"Heuristic Rules\",\"attributes\":{\"a\":\"https:\/\/github.com\/memspace\/zefyr\/blob\/master\/doc\/heuristics.md\"}},{\"insert\":\"\\n\",\"attributes\":{\"block\":\"ul\"}},{\"insert\":\"FAQ\",\"attributes\":{\"a\":\"https:\/\/github.com\/memspace\/zefyr\/blob\/master\/doc\/faq.md\"}},{\"insert\":\"\\n\",\"attributes\":{\"block\":\"ul\"}},{\"insert\":\"Clean and modern look\"},{\"insert\":\"\\n\",\"attributes\":{\"heading\":2}},{\"insert\":\"Zefyr\u2019s rich text editor is built with simplicity and flexibility in mind. It provides clean interface for distraction-free editing. Think Medium.com-like experience.\\nMarkdown inspired semantics\"},{\"insert\":\"\\n\",\"attributes\":{\"heading\":2}},{\"insert\":\"Ever needed to have a heading line inside of a quote block, like this:\\nI\u2019m a Markdown heading\"},{\"insert\":\"\\n\",\"attributes\":{\"block\":\"quote\",\"heading\":3}},{\"insert\":\"And I\u2019m a regular paragraph\"},{\"insert\":\"\\n\",\"attributes\":{\"block\":\"quote\"}},{\"insert\":\"Code blocks\"},{\"insert\":\"\\n\",\"attributes\":{\"heading\":2}},{\"insert\":\"Of course:\\nimport \u2018package:flutter\/material.dart\u2019;\"},{\"insert\":\"\\n\",\"attributes\":{\"block\":\"code\"}},{\"insert\":\"import \u2018package:zefyr\/zefyr.dart\u2019;\"},{\"insert\":\"\\n\\n\",\"attributes\":{\"block\":\"code\"}},{\"insert\":\"void main() {\"},{\"insert\":\"\\n\",\"attributes\":{\"block\":\"code\"}},{\"insert\":\" runApp(MyWAD());\"},{\"insert\":\"\\n\",\"attributes\":{\"block\":\"code\"}},{\"insert\":\"}\"},{\"insert\":\"\\n\",\"attributes\":{\"block\":\"code\"}},{\"insert\":\"\\n\\n\\n\"}]},{\"title\":\"B\",\"date\":\"2019-02-01 11:12:13\",\"text\":[{\"insert\":\"Zefyr2\"},{\"insert\":\"\\n\\n\\n\"}]"}

Please note that the json in “text” is escaped. We also need to edit list_page, where we load it in to load from a single Note instead of an array of notes:

Future<List<Note>> _loadFakeNote() async {
    var response =
        await DefaultAssetBundle.of(context).loadString("assets/text.json");
    var notes = Note.fromJsonResponse(response);
    // notes.sort();
    List<Note> notesList = List<Note>();
    notesList.add(notes);
    return notesList;
  }

We also forgot to call setState when we load a fake note so it was never going to actually show them on screen. Edit initState:

 @override
  void initState() {
    super.initState();
    _loadNotes().then((onValue) {
      if (onValue.length == 0) {
        _loadFakeNote().then((onValue) {
          setState(() {
            _notes = onValue;
          });
        }).catchError(print);
      } else {
        setState(() {
          _notes = onValue;
        });
      }
    }).catchError(print);
  }

And finally we can remove the old function from the Note class since we’re not using anymore. REMOVE the allFromResponse function:

//REMOVE IT
 static List<Note> allFromResponse(String response) {
    var decodedJson = json.decode(response);
    var casted = decodedJson.cast<String, dynamic>();

    return casted['results']
        .cast<Map<String, dynamic>>()
        .map((obj) => Note.fromMap(obj))
        .toList()
        .cast<Note>();
  }

Now we only need to sort using that field when we retrieve the notes in the list_page.

  Future<List<Note>> _loadNotes() async {
    // http.Response response = await http.get('https://randomuser.me/api/?results=25');
    var fm = new FileManager();
    var res = await fm.getNotes();
    res.sort();
    return res;
  }

  Future<List<Note>> _loadFakeNote() async {
    var response =
        await DefaultAssetBundle.of(context).loadString("assets/text.json");
    var notes = Note.allFromResponse(response);
    notes.sort();
    return notes;
  }

Sort is not going to work though since our Notes are not Comparable yet. Go to note.dart and make the class implement comparable:

class Note implements Comparable<Note> {

...

@override
  int compareTo(Note other) {
    return other.lastModified.compareTo(lastModified);
  }

Next it would be nice to have the ability to add a title to a Note. By doing so we first need to address an issue related to the filename of the json files. Righe now we have 2 different filenames if we have or don’t have a title, but like that if we first save a note without a title and later on save that same note with a title we are not going to override the note, but we are going to create a second note with the same content, but different title. It would be better to remove the title from the filename entirely. If you want to keep it you need to find a way to retrieve a note from disk and override it based on its creation date (and content).

Go to file_manger.dart and edit:

  Future<File> writeNote(Note note) async {
    var file = await _localPath;
    file = p.join(
        file,
        DateFormat('yyyy-MM-dd hh:mm:ss').format(note.date)); //add timestamp

Now we can go back to editing the title.

Open full_page.dart. We are going to add a Textfield instead of the logo in the AppBar to keep our title.

class _FullPageEditorScreenState extends State<FullPageEditorScreen> {

...


  TextEditingController _titleController;

  @override
  void initState() {
    super.initState();
    _controller = ZefyrController(NotusDocument.fromDelta(
        Delta.fromJson(json.decode(widget.note.text) as List)));
    _sub = _controller.document.changes.listen((change) {
      print('${change.source}: ${change.change}');
    });
    _titleController = new TextEditingController(text: widget.note.title);
    _titleController.addListener(_editTitle);

    if (widget.openOnEditing) {
      _startEditing();
    }
  }

  ...


  @override
  void dispose() {
    _titleController.dispose();
    _sub.cancel();
    super.dispose();
  }

When we create the Scaffold instead of creating the logo we need to return a TextField:

return Scaffold(
      resizeToAvoidBottomPadding: true,
      appBar: AppBar(
        elevation: 1.0,
        title: TextField(
          controller: _titleController,
          autofocus: false,
          enabled: _editing,
          textInputAction: TextInputAction.done,
          textCapitalization: TextCapitalization.words,
        ),
        actions: done,
      ),

Finally we can add the _editTitle function to handle the text change events:

void _editTitle() {
    var text = _titleController.text;
    if (text.isEmpty) {
      setState(() {
        widget.note.changeNoteTitle('Untitled');
      });
    } else {
      setState(() {
        widget.note.changeNoteTitle(text);
      });
    }
  }

The textfield still doesn’t feel at home though. It is black when it should be white for once. Go back to the code where we instantiate it and add some decorations and styles to it:

title: TextField(
          controller: _titleController,
          autofocus: false,
          enabled: _editing,
          textInputAction: TextInputAction.done,
          textCapitalization: TextCapitalization.words,
          cursorColor: Colors.white,
          style: new TextStyle(color: Colors.white),
          decoration: InputDecoration(
            labelStyle: TextStyle(color: Colors.white),
            focusedBorder: UnderlineInputBorder(
              borderSide: BorderSide(color: Colors.white),
            ),
            disabledBorder: UnderlineInputBorder(
              borderSide: BorderSide(color: Colors.white),
            ),
            border: UnderlineInputBorder(
              borderSide: BorderSide(color: Colors.white),
            ),
            enabledBorder: UnderlineInputBorder(
              borderSide: BorderSide(color: Colors.white),
            ),
            fillColor: Colors.white,
          ),
        ),
        actions: done,

One last feature that we are going to surely need is the ability to delete a note. We can add a button next to the edit button (only when in editing mode) to delete it.

When we create the edit button (in full_page.dart) we can return the delete button as well:

  final done = _editing
        ? [
            new FlatButton(
                onPressed: _deleteNote,
                child: Text(
                  'DELETE',
                  style: TextStyle(
                      fontWeight: FontWeight.bold,
                      color: Colors.white),
                )),
            new FlatButton(
                onPressed: _stopEditing,
                child: Text(
                  'DONE',
                  style: TextStyle(color: Colors.white),
                ))
          ]

Note how we’re making the button bold this time to make it stand out. We also need to add the _deleteNote function to actually delete the note:

void _deleteNote() {
    fm.deleteNote(widget.note).then((res) {
      print('deleted');
      print(res);
      Navigator.of(context).pop();
    }).catchError(print);
  }

Here we delete the note and then go back to the list view. Now we also need to implement the method “deleteNote” from file_manager.dart. First we add the function to get the full path to the article since we are going to need it both when saving and when deleting it:

String getFullFileName(String file, Note note) {
    return p.join(file,
        DateFormat('yyyy-MM-dd hh:mm:ss').format(note.date)); //add timestamp
  }

Done that we can call that function from createNote:

Future<File> writeNote(Note note) async {
    var file = await _localPath;
    file = getFullFileName(file, note);

And last we can implement the deleteNote function:

Future<FileSystemEntity> deleteNote(Note note) async {
    var file = await _localPath;
    file = getFullFileName(file, note);

    final exists = await File(file)
        .exists(); //Readme is a file not saved to the filesystem so we first need to check if it exists
    if (exists) {
      return File(file).delete();
    }
    return null;
  }

Note how we take care to check if the file exists before actually going ahead and deleting it.

Recap

Let’s recap what you did!

  • You’ve created a Flutter Application that can save its state to disk.
  • You’ve seen how to work with async/await/Futures and Streams.
  • You’ve seen how to work with TextFields.
  • You’ve seen how to work with different screens and now to pass changes between them.

  1. You’re actually going to end up with something better since I’ve found and fixed a few problems since shooting the video and I have included them in the tutorial.

  2. The example is for Dart for Angular, but it applies everywhere.