DEV Community

Andrei Lesnitsky
Andrei Lesnitsky

Posted on

TodoList with Flutter

Todo List built with Flutter


Awesome Flutter

Built with Git Tutor
GitHub Source code

This tutorial will walk you through the process of building of a simple todo-list with Flutter

Getting started

Make sure to complete flutter installation

First steps

Execute in your terminal

flutter create todo_list
Enter fullscreen mode Exit fullscreen mode

First line is an import of material library provided by Flutter. This library is an implementation of various android components

📄 lib/main.dart

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

Enter fullscreen mode Exit fullscreen mode

This function is an entry point of flutter application. It calls just runApp, but we can do more in this function (like making your application full-screen).

📄 lib/main.dart

  import 'package:flutter/material.dart';
+
+ void main() => runApp(MyApp());

Enter fullscreen mode Exit fullscreen mode

Let's actually do this 😏

📄 lib/main.dart

  import 'package:flutter/material.dart';
+ import 'package:flutter/services.dart';

- void main() => runApp(MyApp());
+ void main() {
+   SystemChrome.setEnabledSystemUIOverlays([]);
+   runApp(MyApp());
+ }

Enter fullscreen mode Exit fullscreen mode

Every component in flutter is called widget. It could be either stateless (read - pure) or stateful (container for some state). Top-level app component should be a stateless components, so let's create one

📄 lib/main.dart

    SystemChrome.setEnabledSystemUIOverlays([]);
    runApp(MyApp());
  }
+
+ class MyApp extends StatelessWidget {}

Enter fullscreen mode Exit fullscreen mode

Every widget should override build function. It returns a hierarchy of your layout widgets (Container, Padding, Flex, etc) or your stateful widgets which contain some business logic

📄 lib/main.dart

    runApp(MyApp());
  }

- class MyApp extends StatelessWidget {}
+ class MyApp extends StatelessWidget {
+   @override
+   Widget build(BuildContext context) {
+     return Container();
+   }
+ }

Enter fullscreen mode Exit fullscreen mode

But in case of top-level App widget, it should return either CupertinoApp from 'package:flutter/material.dart', or MaterialApp from 'package:flutter/material.dart'

We'll use material in this tutorial

📄 lib/main.dart

  class MyApp extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
-     return Container();
+     return MaterialApp();
    }
  }

Enter fullscreen mode Exit fullscreen mode

Let's add title

📄 lib/main.dart

  class MyApp extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
-     return MaterialApp();
+     return MaterialApp(
+       title: 'Todo List',
+     );
    }
  }

Enter fullscreen mode Exit fullscreen mode

Let's also make a Scaffold a home of our application

Scaffold is a helper class from material library which implements basic app layout (app bar, floating action button)

📄 lib/main.dart

    Widget build(BuildContext context) {
      return MaterialApp(
        title: 'Todo List',
+       home: Scaffold(
+       ),
      );
    }
  }

Enter fullscreen mode Exit fullscreen mode

Now we need to add an application header which will display our app title

📄 lib/main.dart

      return MaterialApp(
        title: 'Todo List',
        home: Scaffold(
+         appBar: AppBar(title: Text('Todo List')),
        ),
      );
    }

Enter fullscreen mode Exit fullscreen mode

And finally the body of our app is todolist itself. Let's just add this line and implement the class later

📄 lib/main.dart

        title: 'Todo List',
        home: Scaffold(
          appBar: AppBar(title: Text('Todo List')),
+         body: TodoList(),
        ),
      );
    }

Enter fullscreen mode Exit fullscreen mode

Render list

Basic statefull widget will look like this

📄 lib/todo_list.dart

import 'package:flutter/material.dart';

class TodoList extends StatefulWidget {
  @override
  _TodoListState createState() => _TodoListState();
}

class _TodoListState extends State<TodoList> {
  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

Enter fullscreen mode Exit fullscreen mode

We also need to import our TodoList widget

📄 lib/main.dart

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

+ import 'package:todo_list/todo_list.dart';
+
  void main() {
    SystemChrome.setEnabledSystemUIOverlays([]);
    runApp(MyApp());

Enter fullscreen mode Exit fullscreen mode

Now let's describe Todo entity as class

📄 lib/todo.dart

class Todo {
  Todo({this.title, this.isDone = false});

  String title;
  bool isDone;
}

Enter fullscreen mode Exit fullscreen mode

and import it to TodoList

📄 lib/todo_list.dart

  import 'package:flutter/material.dart';
+ import 'package:todo_list/todo.dart';

  class TodoList extends StatefulWidget {
    @override

Enter fullscreen mode Exit fullscreen mode

Now we need to extend our TodoList state and add a list of todos

📄 lib/todo_list.dart

  }

  class _TodoListState extends State<TodoList> {
+   List<Todo> todos = [];
+
    @override
    Widget build(BuildContext context) {
      return Container();

Enter fullscreen mode Exit fullscreen mode

Let's use ListView to render our todo items.

📄 lib/todo_list.dart

  class _TodoListState extends State<TodoList> {
    List<Todo> todos = [];

+   _buildItem() {}
+
    @override
    Widget build(BuildContext context) {
-     return Container();
+     return ListView.builder(
+       itemBuilder: _buildItem,
+       itemCount: todos.length,
+     );
    }
  }

Enter fullscreen mode Exit fullscreen mode

Now we're going to implement _buildItem which will be called each time todo has to be rendered

We'll use CheckboxListTile from material library as it has everything we need (checkbox indicating whether todo is completed and title)

📄 lib/todo_list.dart

  class _TodoListState extends State<TodoList> {
    List<Todo> todos = [];

-   _buildItem() {}
+   Widget _buildItem(BuildContext context, int index) {
+     final todo = todos[index];
+
+     return CheckboxListTile(
+     );
+   }

    @override
    Widget build(BuildContext context) {

Enter fullscreen mode Exit fullscreen mode

Value indicates if list item should be checked

📄 lib/todo_list.dart

      final todo = todos[index];

      return CheckboxListTile(
+       value: todo.isDone,
      );
    }


Enter fullscreen mode Exit fullscreen mode

Title is a widget which should be rendered in first row. Typically it is a Text widget

📄 lib/todo_list.dart


      return CheckboxListTile(
        value: todo.isDone,
+       title: Text(todo.title),
      );
    }


Enter fullscreen mode Exit fullscreen mode

Finally we need to handle taps on every list item

📄 lib/todo_list.dart

      return CheckboxListTile(
        value: todo.isDone,
        title: Text(todo.title),
+       onChanged: (bool isChecked) {
+         _toggleTodo(todo, isChecked);
+       },
      );
    }


Enter fullscreen mode Exit fullscreen mode

_toggleTodo implementation is pretty straightforward

📄 lib/todo_list.dart

  class _TodoListState extends State<TodoList> {
    List<Todo> todos = [];

+   _toggleTodo(Todo todo, bool isChecked) {
+     todo.isDone = isChecked;
+   }
+
    Widget _buildItem(BuildContext context, int index) {
      final todo = todos[index];


Enter fullscreen mode Exit fullscreen mode

Let's try to add some mock todos and see if they are rendered correctly

📄 lib/todo_list.dart

  }

  class _TodoListState extends State<TodoList> {
-   List<Todo> todos = [];
+   List<Todo> todos = [
+     Todo(title: 'Learn Dart'),
+     Todo(title: 'Try Flutter'),
+     Todo(title: 'Be amazed'),
+   ];

    _toggleTodo(Todo todo, bool isChecked) {
      todo.isDone = isChecked;

Enter fullscreen mode Exit fullscreen mode

Ok, everything is rendered correctly, but nothing happens when we tap on items, weird..

Let's add a debug print and see if the handler even called

📄 lib/todo_list.dart

    ];

    _toggleTodo(Todo todo, bool isChecked) {
+     print('${todo.title} ${todo.isDone}');
+
      todo.isDone = isChecked;
    }


Enter fullscreen mode Exit fullscreen mode

Console shows items are checked, value isChecked is true, but checkbox is never rendered

The problem is that we modify our entities, but flutter has no idea this happened, so we need to call setState. (Hi there, react fans! 😏)

📄 lib/todo_list.dart

    ];

    _toggleTodo(Todo todo, bool isChecked) {
-     print('${todo.title} ${todo.isDone}');
-
-     todo.isDone = isChecked;
+     setState(() {
+       todo.isDone = isChecked;
+     });
    }

    Widget _buildItem(BuildContext context, int index) {

Enter fullscreen mode Exit fullscreen mode

Now we're good with rendering and updates, time to get rid of mock items and add some ui to add new todos.

Let's add a FloatingActionButton

📄 lib/main.dart

        home: Scaffold(
          appBar: AppBar(title: Text('Todo List')),
          body: TodoList(),
+         floatingActionButton: FloatingActionButton(
+           child: Icon(Icons.add),
+         ),
        ),
      );
    }

Enter fullscreen mode Exit fullscreen mode

📄 lib/todo_list.dart

  }

  class _TodoListState extends State<TodoList> {
-   List<Todo> todos = [
-     Todo(title: 'Learn Dart'),
-     Todo(title: 'Try Flutter'),
-     Todo(title: 'Be amazed'),
-   ];
+   List<Todo> todos = [];

    _toggleTodo(Todo todo, bool isChecked) {
      setState(() {

Enter fullscreen mode Exit fullscreen mode

Ok, but what should we do in onPressed? We need to access a state of TodoList and messing with children state directly from parent statelsess widget doesn't sound like a good idea

📄 lib/main.dart

          body: TodoList(),
          floatingActionButton: FloatingActionButton(
            child: Icon(Icons.add),
+           onPressed: () {
+             // 😢
+           },
          ),
        ),
      );

Enter fullscreen mode Exit fullscreen mode

So let's just move Scaffold widget down to TodoList

📄 lib/main.dart

    Widget build(BuildContext context) {
      return MaterialApp(
        title: 'Todo List',
-       home: Scaffold(
-         appBar: AppBar(title: Text('Todo List')),
-         body: TodoList(),
-         floatingActionButton: FloatingActionButton(
-           child: Icon(Icons.add),
-           onPressed: () {
-             // 😢
-           },
-         ),
-       ),
+       home: TodoList(),
      );
    }
  }

Enter fullscreen mode Exit fullscreen mode

📄 lib/todo_list.dart

      );
    }

+   _addTodo() {}
+
    @override
    Widget build(BuildContext context) {
-     return ListView.builder(
-       itemBuilder: _buildItem,
-       itemCount: todos.length,
+     return Scaffold(
+       appBar: AppBar(title: Text('Todo List')),
+       body: ListView.builder(
+         itemBuilder: _buildItem,
+         itemCount: todos.length,
+       ),
+       floatingActionButton: FloatingActionButton(
+         child: Icon(Icons.add),
+         onPressed: _addTodo,
+       ),
      );
    }
  }

Enter fullscreen mode Exit fullscreen mode

Now we can show a dialog when user taps on FloatingActionButton

📄 lib/todo_list.dart

      );
    }

-   _addTodo() {}
+   _addTodo() {
+     showDialog(
+       context: context,
+       builder: (BuildContext context) {
+         return AlertDialog(
+           title: Text('New todo'),
+         );
+       },
+     );
+   }

    @override
    Widget build(BuildContext context) {

Enter fullscreen mode Exit fullscreen mode

Dialog will contain a text input:

📄 lib/todo_list.dart

        builder: (BuildContext context) {
          return AlertDialog(
            title: Text('New todo'),
+           content: TextField(),
          );
        },
      );

Enter fullscreen mode Exit fullscreen mode

and two action buttons: Cancel and Add

📄 lib/todo_list.dart

          return AlertDialog(
            title: Text('New todo'),
            content: TextField(),
+           actions: <Widget>[
+             FlatButton(
+               child: Text('Cancel'),
+             ),
+             FlatButton(
+               child: Text('Add'),
+             ),
+           ],
          );
        },
      );

Enter fullscreen mode Exit fullscreen mode

Dialogs are not just overlays, but actually a routes, so to handle Cancel action we can just call .pop on Navigator of current context

📄 lib/todo_list.dart

            actions: <Widget>[
              FlatButton(
                child: Text('Cancel'),
+               onPressed: () {
+                 Navigator.of(context).pop();
+               },
              ),
              FlatButton(
                child: Text('Add'),

Enter fullscreen mode Exit fullscreen mode

Now we need to access the value of a TextField to create a Todo
To do this we need to create a TextEditingController

📄 lib/todo_list.dart

  class _TodoListState extends State<TodoList> {
    List<Todo> todos = [];

+   TextEditingController controller = new TextEditingController();
+
    _toggleTodo(Todo todo, bool isChecked) {
      setState(() {
        todo.isDone = isChecked;

Enter fullscreen mode Exit fullscreen mode

and supply it to the TextField

📄 lib/todo_list.dart

        builder: (BuildContext context) {
          return AlertDialog(
            title: Text('New todo'),
-           content: TextField(),
+           content: TextField(controller: controller),
            actions: <Widget>[
              FlatButton(
                child: Text('Cancel'),

Enter fullscreen mode Exit fullscreen mode

now in onPressed of Add action we can log the value of a TextField and clear it

📄 lib/todo_list.dart

              ),
              FlatButton(
                child: Text('Add'),
+               onPressed: () {
+                 print(controller.value.text);
+                 controller.clear();
+               },
              ),
            ],
          );

Enter fullscreen mode Exit fullscreen mode

Finally let's actually create new todo and add it to the list of existing todos (don't forget to wrap the code with setState)

📄 lib/todo_list.dart

              FlatButton(
                child: Text('Add'),
                onPressed: () {
-                 print(controller.value.text);
-                 controller.clear();
+                 setState(() {
+                   final todo = new Todo(title: controller.value.text);
+
+                   todos.add(todo);
+                   controller.clear();
+
+                   Navigator.of(context).pop();
+                 });
                },
              ),
            ],

Enter fullscreen mode Exit fullscreen mode

Tiny UX improvement: make keyboard pop automatically by passing autofocus: true to a TextField

📄 lib/todo_list.dart

        builder: (BuildContext context) {
          return AlertDialog(
            title: Text('New todo'),
-           content: TextField(controller: controller),
+           content: TextField(
+             controller: controller,
+             autofocus: true,
+           ),
            actions: <Widget>[
              FlatButton(
                child: Text('Cancel'),

Enter fullscreen mode Exit fullscreen mode

Refactoring

TodoListis working, but todo_list.dart is kinda messy and hard to read. The most complex method is _addTodo, so let's start with rewriting it. It seems like we can move the AlertDialog to a separate widget, but we can't do this right now, as we rely on setState from parent widget. Instead we can pass a freshly created todo to a Navigator.pop

📄 lib/todo_list.dart

    }

    _addTodo() {
-     showDialog(
+     showDialog<Todo>(
        context: context,
        builder: (BuildContext context) {
          return AlertDialog(
              FlatButton(
                child: Text('Add'),
                onPressed: () {
-                 setState(() {
-                   final todo = new Todo(title: controller.value.text);
+                 final todo = new Todo(title: controller.value.text);
+                 controller.clear();

-                   todos.add(todo);
-                   controller.clear();
-
-                   Navigator.of(context).pop();
-                 });
+                 Navigator.of(context).pop(todo);
                },
              ),
            ],

Enter fullscreen mode Exit fullscreen mode

In order to be able to receive the Todo in _addTodo method we need to make it async and await showDialog function result (which will be null in case it was dismissed and instance of Todo otherwise)

📄 lib/todo_list.dart

      );
    }

-   _addTodo() {
-     showDialog<Todo>(
+   _addTodo() async {
+     final todo = await showDialog<Todo>(
        context: context,
        builder: (BuildContext context) {
          return AlertDialog(

Enter fullscreen mode Exit fullscreen mode

And move back the logic with state update

📄 lib/todo_list.dart

          );
        },
      );
+
+     if (todo != null) {
+       setState(() {
+         todos.add(todo);
+       });
+     }
    }

    @override

Enter fullscreen mode Exit fullscreen mode

Now we don't have any dependencies on a parent widget, so we can extract AlertDialog to a separate widget

📄 lib/new_todo_dialog.dart

import 'package:flutter/material.dart';

import 'package:todo_list/todo.dart';

class NewTodoDialog extends StatelessWidget {
  final controller = new TextEditingController();

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      title: Text('New todo'),
      content: TextField(
        controller: controller,
        autofocus: true,
      ),
      actions: <Widget>[
        FlatButton(
          child: Text('Cancel'),
          onPressed: () {
            Navigator.of(context).pop();
          },
        ),
        FlatButton(
          child: Text('Add'),
          onPressed: () {
            final todo = new Todo(title: controller.value.text);
            controller.clear();

            Navigator.of(context).pop(todo);
          },
        ),
      ],
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

and use it inside TodoList

📄 lib/todo_list.dart

  import 'package:flutter/material.dart';
  import 'package:todo_list/todo.dart';

+ import 'package:todo_list/new_todo_dialog.dart';
+
  class TodoList extends StatefulWidget {
    @override
    _TodoListState createState() => _TodoListState();
  class _TodoListState extends State<TodoList> {
    List<Todo> todos = [];

-   TextEditingController controller = new TextEditingController();
-
    _toggleTodo(Todo todo, bool isChecked) {
      setState(() {
        todo.isDone = isChecked;
      final todo = await showDialog<Todo>(
        context: context,
        builder: (BuildContext context) {
-         return AlertDialog(
-           title: Text('New todo'),
-           content: TextField(
-             controller: controller,
-             autofocus: true,
-           ),
-           actions: <Widget>[
-             FlatButton(
-               child: Text('Cancel'),
-               onPressed: () {
-                 Navigator.of(context).pop();
-               },
-             ),
-             FlatButton(
-               child: Text('Add'),
-               onPressed: () {
-                 final todo = new Todo(title: controller.value.text);
-                 controller.clear();
-
-                 Navigator.of(context).pop(todo);
-               },
-             ),
-           ],
-         );
+         return NewTodoDialog();
        },
      );


Enter fullscreen mode Exit fullscreen mode

Next step – extract todo list component

List istself could also be treated as stateless widget, state related logic could be handled by parent

So let's first rename TodoList to TodoListScreen

📄 lib/todo_list.dart


  import 'package:todo_list/new_todo_dialog.dart';

- class TodoList extends StatefulWidget {
+ class TodoListScreen extends StatefulWidget {
    @override
-   _TodoListState createState() => _TodoListState();
+   _TodoListScreenState createState() => _TodoListScreenState();
  }

- class _TodoListState extends State<TodoList> {
+ class _TodoListScreenState extends State<TodoListScreen> {
    List<Todo> todos = [];

    _toggleTodo(Todo todo, bool isChecked) {

Enter fullscreen mode Exit fullscreen mode

rename file

📄 lib/todo_list_screen.dart => lib/todo_list.dart

and fix import

📄 lib/main.dart

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

- import 'package:todo_list/todo_list.dart';
+ import 'package:todo_list/todo_list_screen.dart';

  void main() {
    SystemChrome.setEnabledSystemUIOverlays([]);
    Widget build(BuildContext context) {
      return MaterialApp(
        title: 'Todo List',
-       home: TodoList(),
+       home: TodoListScreen(),
      );
    }
  }

Enter fullscreen mode Exit fullscreen mode

Let's move list related logic to a separate stateless widget

📄 lib/todo_list.dart

import 'package:flutter/material.dart';

class TodoList extends StatelessWidget {
  _toggleTodo(Todo todo, bool isChecked) {
    setState(() {
      todo.isDone = isChecked;
    });
  }

  Widget _buildItem(BuildContext context, int index) {
    final todo = todos[index];

    return CheckboxListTile(
      value: todo.isDone,
      title: Text(todo.title),
      onChanged: (bool isChecked) {
        _toggleTodo(todo, isChecked);
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemBuilder: _buildItem,
      itemCount: todos.length,
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

and remove this logic from TodoListScreen

📄 lib/todo_list_screen.dart

  import 'package:todo_list/todo.dart';

  import 'package:todo_list/new_todo_dialog.dart';
+ import 'package:todo_list/todo_list.dart';

  class TodoListScreen extends StatefulWidget {
    @override
  class _TodoListScreenState extends State<TodoListScreen> {
    List<Todo> todos = [];

-   _toggleTodo(Todo todo, bool isChecked) {
-     setState(() {
-       todo.isDone = isChecked;
-     });
-   }
-
-   Widget _buildItem(BuildContext context, int index) {
-     final todo = todos[index];
-
-     return CheckboxListTile(
-       value: todo.isDone,
-       title: Text(todo.title),
-       onChanged: (bool isChecked) {
-         _toggleTodo(todo, isChecked);
-       },
-     );
-   }
-
    _addTodo() async {
      final todo = await showDialog<Todo>(
        context: context,
    Widget build(BuildContext context) {
      return Scaffold(
        appBar: AppBar(title: Text('Todo List')),
-       body: ListView.builder(
-         itemBuilder: _buildItem,
-         itemCount: todos.length,
-       ),
+       body: TodoList(),
        floatingActionButton: FloatingActionButton(
          child: Icon(Icons.add),
          onPressed: _addTodo,

Enter fullscreen mode Exit fullscreen mode

Now let's review our TodoList widget

It is missing Todo class import

📄 lib/todo_list.dart

  import 'package:flutter/material.dart';

+ import 'package:todo_list/todo.dart';
+
  class TodoList extends StatelessWidget {
    _toggleTodo(Todo todo, bool isChecked) {
      setState(() {

Enter fullscreen mode Exit fullscreen mode

It also doesn't have todos, so let's pass them from parent widget

📄 lib/todo_list.dart

  import 'package:todo_list/todo.dart';

  class TodoList extends StatelessWidget {
+   TodoList({@required this.todos});
+
+   final List<Todo> todos;
+
    _toggleTodo(Todo todo, bool isChecked) {
      setState(() {
        todo.isDone = isChecked;

Enter fullscreen mode Exit fullscreen mode

📄 lib/todo_list_screen.dart

    Widget build(BuildContext context) {
      return Scaffold(
        appBar: AppBar(title: Text('Todo List')),
-       body: TodoList(),
+       body: TodoList(
+         todos: todos,
+       ),
        floatingActionButton: FloatingActionButton(
          child: Icon(Icons.add),
          onPressed: _addTodo,

Enter fullscreen mode Exit fullscreen mode

_toggleTodo method relies on setState, so let's move it back to parent

📄 lib/todo_list.dart


    final List<Todo> todos;

-   _toggleTodo(Todo todo, bool isChecked) {
-     setState(() {
-       todo.isDone = isChecked;
-     });
-   }
-
    Widget _buildItem(BuildContext context, int index) {
      final todo = todos[index];


Enter fullscreen mode Exit fullscreen mode

📄 lib/todo_list_screen.dart

  class _TodoListScreenState extends State<TodoListScreen> {
    List<Todo> todos = [];

+   _toggleTodo(Todo todo, bool isChecked) {
+     setState(() {
+       todo.isDone = isChecked;
+     });
+   }
+
    _addTodo() async {
      final todo = await showDialog<Todo>(
        context: context,

Enter fullscreen mode Exit fullscreen mode

and pass it down to TodoList as a property

📄 lib/todo_list.dart


  import 'package:todo_list/todo.dart';

+ typedef ToggleTodoCallback = void Function(Todo, bool);
+
  class TodoList extends StatelessWidget {
-   TodoList({@required this.todos});
+   TodoList({@required this.todos, this.onTodoToggle});

    final List<Todo> todos;
+   final ToggleTodoCallback onTodoToggle;

    Widget _buildItem(BuildContext context, int index) {
      final todo = todos[index];
        value: todo.isDone,
        title: Text(todo.title),
        onChanged: (bool isChecked) {
-         _toggleTodo(todo, isChecked);
+         onTodoToggle(todo, isChecked);
        },
      );
    }

Enter fullscreen mode Exit fullscreen mode

📄 lib/todo_list_screen.dart

        appBar: AppBar(title: Text('Todo List')),
        body: TodoList(
          todos: todos,
+         onTodoToggle: _toggleTodo,
        ),
        floatingActionButton: FloatingActionButton(
          child: Icon(Icons.add),

Enter fullscreen mode Exit fullscreen mode

Conclusion

Yay! We have working and kinda well-structured Todo List application written in Flutter 🎉

But there is still a lot of work to do:

App-Screenshot-3.png

See you in next tutorials! 👋

Author

Andrei Lesnitsky [Twitter | Email]

Built with Git Tutor

Top comments (0)