A To-do app is a program for managing tasks or activities that you intend to do at some point. It is a classic programming project for beginners, especially for those learning to create graphical user interfaces or GUIs for the desktop.
In this tutorial, we will learn how to create a minimal To-do app with Kivy. The app will allow you to create new tasks, save them to an SQLite database, mark them as done, and remove them when finished.
Setting Up the Working Environment
In this tutorial, we'll use the Kivy library to build the To-do app's GUI. So, we assume that you have a basic understanding of Kivy's widgets and apps.
To learn the basics about Kivy, check out the Getting Started With Kivy for GUI Development tutorial.
For the database functionalities, we will use the sqlite3
package from the Python standard library. To use sqlite3
yourself you will need to have some basic SQL knowledge However, if you are not familiar with SQL, don't fret, we won't be going deep into that topic & have working examples you can copy.
With that in mind, let's create a virtual environment and install Kivy in it. To do this, you can run the following commands:
$ mkdir todo/
$ cd todo
$ python -m venv venv
$ source venv/bin/activate
(venv)$ pip install kivy
With these commands, you create a todo/
folder for storing your project. Inside that folder, you create a new virtual environment, activate it, and install Kivy from PyPI.
Purchasing Power Parity
Developers in [[ country ]] get [[ discount.discount_pc ]]% OFF on all books & courses with code [[ discount.coupon_code ]]The commands for Windows will slightly differ from the commands above. For platform-specific commands, check the Working With Python Virtual Environments tutorial.
Creating the To-do App's Project Layout
To begin with the To-do app, we will create three files in the todo/
directory. This way, we will separate the GUI-related code from the database logic:
- A
main.py
file where we will have our KivyApp
subclass - A
widgets.py
file containing widget classes or GUI components - A
database.py
containing the database-related code
Throughout the tutorial, we will work on these three files simultaneously. You can open them in your favorite code editor.
Developing the To-do App's Interface
Before we proceed, let's take a look at how our To-do application will look like when we finish writing the code:
Kivy To-do app demo
As you can see in the image above, the app's window displays the name followed by a text input field and a list of tasks. In addition to the text input field, we have a + button, which we will use to add the task description in the text field to the to-do list below.
Next are the tasks or to-do items. Each item contains the following widgets:
- A button displaying the to-do task description
- A Done button to mark an item as done
- A - button to remove the item from the list
To create this interface, let's go to the main.py
file and import the App
and Window
classes from their corresponding modules:
from kivy.app import App
from kivy.core.window import Window
It's important to note that the Window
class is not intended to create the application's window. We will use this class to set the background color of our app's window.
Now, we can quickly head over to our widgets.py
file and create our root
widget, which we will call MainWindow
. To do this, we will subclass the FloatLayout
class:
from kivy.uix.floatlayout import FloatLayout
TEAL = (0, 0.31, 0.31, 1.0)
class MainWindow(FloatLayout):
def __init__(self, **kwargs):
super().__init__(**kwargs)
Before defining MainWindow
, we have a color tuple, TEAL
, for defining a teal color in RGBA format. We will use this color to give the app's window a teal background.
Changing the values of the tuple will vary the window's background color. So, you can play around with the values and tweak the color as you prefer. Then, we define the MainWindow
class as a subclass of Kivy's FloatLayout
.
Now, let's head to main.py
and import our recently defined MainWindow
class. Let's also import the MainWindow
class and the TEAL
constant at once, and set the window's background color:
from kivy.app import App
from kivy.core.window import Window
from widgets import MainWindow, TEAL
Window.clearcolor = TEAL
Then, we need to define a TodoApp
class by inheriting from App
. Let's name this class TodoApp
:
# ...
class TodoApp(App):
title = "Todo App"
def build(self):
return MainWindow()
if __name__ == "__main__":
todoapp = TodoApp()
todoapp.run()
Inside the TodoApp
class, we need a build()
method that returns an instance of MainWindow
. This instance acts as the root widget upon which every other widget in our app will be added.
At this point, we will have the following code in our main.py
file:
from kivy.app import App
from kivy.core.window import Window
from widgets import MainWindow, TEAL
Window.clearcolor = TEAL
class TodoApp(App):
title = "Todo App"
def build(self):
return MainWindow()
if __name__ == "__main__":
todoapp = TodoApp()
todoapp.run()
Also, we should have the following code in our widgets.py
file:
from kivy.uix.floatlayout import FloatLayout
TEAL = (0, 0.31, 0.31, 1.0)
class MainWindow(FloatLayout):
def __init__(self, **kwargs):
super().__init__(**kwargs)
Running the main.py
file, you should get a blank teal window like the following on your screen:
Kivy To-do app's main window
Once again back on the widgets.py
file, let's import all the widget classes that we will need:
from kivy.effects.scroll import ScrollEffect
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.label import Label
from kivy.uix.scrollview import ScrollView
from kivy.uix.textinput import TextInput
We will use all these Kivy widgets to build our app's GUI. But first, let's define some more colors:
# ...
TEAL = (0, 0.31, 0.31, 1.0)
YELLOW = (1.0, 0.85, 0, 1.0)
LIGHT_TEAL = (0, 0.41, 0.41, 1.0)
# ...
Next, we modify the MainWindow
class by adding a couple of widgets:
# ...
class MainWindow(FloatLayout):
def __init__(self, **kwargs):
super().__init__(**kwargs)
todo_list_container = BoxLayout(
orientation="vertical",
size_hint=[.85, None],
height=350,
pos_hint={"center_x":0.5, "top":0.85},
spacing=10
)
title_label = Label(
font_size=35,
text="[b]Todo App[/b]",
size_hint=[1, None],
markup=True,
)
todo_list_container.add_widget(title_label)
self.add_widget(todo_list_container)
In the class initializer, we have a BoxLayout
instance called todo_list_container
, with a vertical orientation, a specified size and position, and some spacing.
In addition to that, we have a label called title_label
. This label will display the app's name, "Todo App"
, in a larger font size and formatted as bold text. Finally, we add the label to the todo_list_container
, and the todo_list_container
to the MainWindow
.
Run main.py
again. The app's interface should look something like this now:
Kivy To-do app's main window with the title
From the image above, we notice that the label is centered on the app's window. This is because no other widgets have been added to the todo_list_container
layout yet.
Now, let's create some more classes. First, we create a subclass of TextInput
and call it Input
. Our aim with this class is to limit the number of characters that the text input. In this example, we chose to allow for 65 characters:
# ...
class Input(TextInput):
max_length = 65
multiline = False
def insert_text(self, *args):
if len(self.text) < self.max_length:
super().insert_text(*args)
What we've done here is override the insert_text()
method of the TextInput
class, adding just a little touch to it. This method is called every time we type a character into the text input, which now only accepts the maximum number of characters given by max_length
.
Next, we'll create some customized Button
subclasses:
# ...
class NoBackgroundButton(Button):
background_down = ""
background_normal = ""
background_disabled = ""
class YellowButton(NoBackgroundButton):
background_color = YELLOW
color = TEAL
class LightTealButton(NoBackgroundButton):
background_color = LIGHT_TEAL
We've created a Button
subclass called NoBackgroundButton
and adjusted some of its parent's properties. We remove the default backgrounds for its pressed, unpressed, and disabled states, allowing us to apply any color to its background without interference from its texture. With that done, we used the NoBackgroundButton
class to define two subclasses with different color setups.
Now, we can create the input field widget, which we will call InputFrame
. This widget will contain Input
and YellowButton
widgets arranged horizontally. To do this, we will use a BoxLayout
widget:
# ...
class InputFrame(BoxLayout):
spacing = 8
height = 45
size_hint_y = None
def __init__(self, main_window, **kwargs):
super().__init__(**kwargs)
self.todo_input_widget = Input(
hint_text="Enter a todo activity",
font_size=22
)
self.todo_input_widget.padding = [10, 10, 10, 10]
add_item_button = YellowButton(
width=self.height,
size_hint=[None, 1], text="+"
)
add_item_button.bind(
on_release=lambda *args: main_window.add_todo_item(
self.todo_input_widget.text
)
)
self.add_widget(self.todo_input_widget)
self.add_widget(add_item_button)
The above code defines the InputFrame
class, which inherits from BoxLayout
. The InputFrame
class sets some properties, such as spacing and height. Its size_hint_y
property was disabled, so we could set an absolute value for its height.
In the class initializer, we create an instance of Input
named todo_input_widget
, with its hint text set to "Enter a todo activity"
. Then, we create an instance of YellowButton
named add_item_button
, which has the text "+"
and is bound to a function that adds a to-do item when clicked.
Finally, todo_input_widget
and add_item_button
are added as widgets to the InputFrame
.
Furthermore, note that we added an argument to its initializer named main_window
. This will be an instance of the MainWindow
class so that we can access its methods.
Now, we just add an InputFrame
widget instance to MainWindow
. However, rather than add it to MainWindow
directly, we add it to the box layout, todo_list_container
:
# ...
class MainWindow(FloatLayout):
def __init__(self, **kwargs):
super().__init__(**kwargs)
# ...
self.inputframe = InputFrame(self)
todo_list_container.add_widget(title_label)
todo_list_container.add_widget(self.inputframe)
# ...
Next, we create the widget that will contain our list of to-do tasks in our widgets.py
file. For this, we will create a ScrollView
subclass, which we will call ScrollableList
:
# ...
class ScrollableList(ScrollView):
effect_cls = ScrollEffect
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.height = 400
First, we set the effect_cls
(read as "effect class") property to ScrollEffect
. This is not entirely necessary, but it helps prevent the ScrollView
from scrolling beyond its contents.
Then, we create a BoxLayout
instance with its orientation
argument set to vertical
. Scroll views only accept one child widget, therefore we will only add a BoxLayout
instance to the ScrollableList
instance. Then, we'll add new to-do items to the BoxLayout
:
# ...
class ScrollableList(ScrollView):
effect_cls = ScrollEffect
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.height = 400
self.todoitems = BoxLayout(
orientation="vertical",
size_hint_y = None,
spacing=14
)
It's important to note that a ScrollView
can only scroll when the height of its child, self.todoitems
in our case, exceeds its own height. This behavior makes sense because otherwise, there wouldn't be anything to scroll to.
Therefore, whenever we add or remove a to-do item from the list, we must dynamically increase or decrease the height of the self.todoitems
layout.
To implement this behavior, we will bind a method adjust_height()
to a change in its property children
:
# ...
class ScrollableList(ScrollView):
effect_cls = ScrollEffect
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.height = 400
self.todoitems = BoxLayout(
orientation="vertical",
size_hint_y = None,
spacing=14
)
self.todoitems.bind(children=self.adjust_height)
self.add_widget(self.todoitems)
def adjust_height(self, *args):
ITEM_HEIGHT = 40
SPACING = 14
self.todoitems.height = (ITEM_HEIGHT + SPACING) * (
len(self.todoitems.children)
) - SPACING
In the ScrollableList
class, the adjust_height()
method increases or decreases the height of the box layout self.todoitems
. This adjustment is made by adding or subtracting the height of an Item
widget and the vertical spacing between each Item
widget when adding or removing items, respectively.
Now, we add an instance of ScrollableList
to our MainWindow
:
# ...
class MainWindow(FloatLayout):
def __init__(self, **kwargs):
super().__init__(**kwargs)
# ...
self.inputframe = InputFrame(self)
self.scrollablelist = ScrollableList()
self.todoitems = self.scrollablelist.todoitems
todo_list_container.add_widget(title_label)
todo_list_container.add_widget(self.inputframe)
todo_list_container.add_widget(self.scrollablelist)
self.add_widget(todo_list_container)
Let's now create the Item
class that will represent each to-do item. To do this, we will use a BoxLayout
class with its default horizontal
orientation:
# ...
class Item(BoxLayout):
size_hint = [1, None]
spacing = 5
def __init__(self, main_window, item_id, todo_item, done=False, **kwargs):
super().__init__(**kwargs)
self.height = 40
self.item_id = item_id
item_display_box = LightTealButton(text=todo_item, size_hint=[0.6, 1])
self.mark_done_button = YellowButton(
text="Done", size_hint=[None, 1], width=100, disabled=done
)
self.mark_done_button.bind(
on_release=lambda *args: main_window.mark_as_done(item_id)
)
remove_button = YellowButton(
text="-",
size_hint=[None, 1],
width=40
)
remove_button.bind(
on_release=lambda *args: main_window.delete_todo_item(item_id)
)
self.add_widget(item_display_box)
self.add_widget(self.mark_done_button)
self.add_widget(remove_button)
We have defined the Item
class inheriting from BoxLayout
. The class will hold a button for displaying the to-do item's description, another button for marking the item as done, and a final button for deleting the item. Furthermore, we bind the self.mark_done_button
and remove_button
buttons to methods in the MainWindow
class.
However, note that we haven't defined the methods that we have bound to the on_release
event of each button. We still won't define them because they contain calls to methods of the Database
class. We will define this class in database.py
and when we are done there, we return to modify widgets.py
as needed.
That aside, we can run the main.py
again and see how far we've come. We should get the same result as shown in the image below:
Kivy To-do app's main window with task input
With this result, let's move to our database.py
file so that we can implement the database operations.
Managing the To-do Database
Now that we are partly done with the GUI, we need to store our to-do items in a database so that we can view, delete, or mark them as done.
If you are not familiar with SQL, do not fret. We will only carry out five basic CRUD (create, read, update, delete) operations. We need code for retrieving, updating, and deleting items. Additionally, we have to create a database table.
Now, let's head over to database.py
and import the sqlite3
and pathlib
modules:
import pathlib
import sqlite3
Then, we set our database filename to todo.db
. This file will live in the project's directory. Next, we will create our Database
class:
# ...
DATABASE_PATH = pathlib.Path(__file__).parent / "todo.db"
class Database:
def __init__(self, db_path=DATABASE_PATH):
self.db = sqlite3.connect(db_path)
self.cursor = self.db.cursor()
self.create_table()
Note that your file name and extension can be anything you want. Common file extensions are .db
, .sqlite
, and .sqlite3
.
Next, we get a database cursor. This object allows us to execute transactions on the SQLite database from our code. Finally, we call the create_table()
method. We'll define this method in a moment.
We will carry out only five operations on the database. Therefore, we need five different SQL queries. To run the queries, we will create five dedicated methods.
To get started, let's define the create_table()
method for creating a database table. A table is a collection of related data organized in rows and columns. We can achieve this in just a few lines:
# ...
class Database:
# ...
def create_table(self):
query = """
CREATE TABLE IF NOT EXISTS todo(
item_id INTEGER PRIMARY KEY,
item TEXT,
done INTEGER
);
"""
self._run_query(query)
def _run_query(self, query, *query_args):
result = self.cursor.execute(query, [*query_args])
self.db.commit()
return result
This method creates a table called todo
. The SQL query is contained in the multiline string assigned to the query
variable. This query creates that table only if it doesn't already exist.
The table will have the following three columns:
item_id
is a unique integer value that defines the to-do item's id.item
is a string value that describes the to-do task.done
is an integer value that can take either a0
or1
as its value. This column will allow us to mark an item as completed or done.
Once you have the target query, you can run it on the database. To do this, you use the _run_query()
method, which takes the query
and *query_args
as arguments. The call to self.cursor.execute()
runs the query on the database while the call to self.db.commit()
saves the changes. Finally, the function returns the query result. You'll use this helper method to define a few other methods in Database
.
We define the add_todo_item()
method. This time for adding an item to the database:
# ...
class Database:
# ...
def add_todo_item(self, item):
self._run_query(
"INSERT INTO todo VALUES (NULL, ?, 0);",
item,
)
This method accepts a string describing a to-do item and adds it to the database with the appropriate SQL query.
Now, let's define three more methods to execute other required operations:
# ...
class Database:
# ...
def delete_todo_item(self, item_id):
self._run_query(
"DELETE FROM todo WHERE item_id=(?);",
item_id,
)
def mark_as_done(self, item_id):
self._run_query(
"UPDATE todo SET done=1 WHERE item_id=?;",
item_id,
)
def retrieve_all_items(self):
result = self._run_query("SELECT * FROM todo;")
return result.fetchall()
The delete_todo_item()
and mark_as_done()
methods use the _run_query()
method to run queries that remove an item form the database and mark an item as done, respectively.
The retrieve_all_items()
method calls fetchall()
on result
, which is an instance of the sqlite3.Cursor
class. This call converts our results to a list so that we can use it latter in the app.
Completing the To-do App's GUI
Returning to widgets.py
, we must define additional methods within the MainWindow
class. Before that, we need to modify its initializer by including an additional argument. This argument will be an instance of the Database
class previously defined in database.py
.
Now, let's update the MainWindow
class:
# ...
class MainWindow(FloatLayout):
def __init__(self, db, **kwargs):
super().__init__(**kwargs)
self.db = db
# ...
Here, we've added a db
argument to the class initializer. This argument will accept an instance of Database
. Then, we create the db
attribute so that we can access the database from other methods.
We'll now define other methods, starting with add_todo_item()
. This method requires an argument called todo_item
, which should be a string representing the to-do task or activity:
# ...
class MainWindow(FloatLayout):
# ...
def add_todo_item(self, todo_item):
if todo_item.isspace() or todo_item == "":
return
self.db.add_todo_item(todo_item)
self.todoitems.clear_widgets()
self.show_existing_items()
self.inputframe.todo_input_widget.text = ""
When add_todo_item()
is called, it verifies whether the todo_item
value contains only whitespaces or is an empty string. If this is the case, then the method returns immediately. Otherwise, the method proceeds to add the to-do activity to the database.
Afterward, the method clears the current list of to-do items using the clear_widgets()
method and then reloads the updated list using the show_existing_items()
method, which we'll define in a moment.
Finally, we remove the current task description by setting the text
property of the Input
instance to an empty string right after adding an item to the database and to-do list.
Now let's define a method called delete_todo_item()
for deleting a to-do item:
# ...
class MainWindow(FloatLayout):
# ...
def delete_todo_item(self, item_id):
for item in self.todoitems.children:
if item.item_id == item_id:
self.db.delete_todo_item(item_id)
item.parent.remove_widget(item)
The method takes an item_id
integer argument, finds the corresponding Item
instance in the to-do list, and removes it from both the database and the to-do list.
Next up, we'll define the .mark_as_done()
method to indicate that an item has been completed:
# ...
class MainWindow(FloatLayout):
# ...
def mark_as_done(self, item_id):
for item in self.todoitems.children:
if item.item_id == item_id:
self.db.mark_as_done(item_id)
item.mark_done_button.disabled = True
This method accepts an item_id
integer argument, but this time, it marks the item as done in the database. It finds the corresponding Item
instance in the list and disables the button used for marking the item as done.
Finally, we will code the show_existing_items()
method. This method will display all the to-do items we have in our database, with the most recent task displayed at the top:
# ...
class MainWindow(FloatLayout):
# ...
def show_existing_items(self):
items = self.db.retrieve_all_items()
for item in reversed(items):
item_id, todo_item, done = item
item = Item(self, item_id, todo_item, done)
self.todoitems.add_widget(item)
Here, we loop through each item in the database and create the corresponding Item
widgets for them. Shortly after creation, each Item
instance is added to the list, self.todoitems
.
Head back to the main.py
and import the Database
class from database.py
as follows:
# ...
from database import Database
from widgets import TEAL, MainWindow
# ...
Afterward, pass an instance of Database
as an argument to MainWindow
in the build()
method:
# ...
class TodoApp(App):
title = "Todo App"
def build(self):
return MainWindow(db=Database())
# ...
Now, go ahead and run main.py
, type in a to-do activity, and add it to the list using the + button. Add other items as you like. Here's a demo of how the app should work:
Kivy To-do app with sample tasks
Conclusion
A journey of a thousand miles begins with a single step. In this tutorial, you have taken your first steps building more complex applications with Kivy. Furthermore, you have been introduced to storing, retrieving, and updating data to and from a local database with SQLite, using Python's sqlite3
library.
Think about some additional features you'd like or expect to see in a Todo application and see if you can add them yourself!
Packaging Python Applications with PyInstaller by Martin Fitzpatrick — This step-by-step guide walks you through packaging your own Python applications from simple examples to complete installers and signed executables.