Building a Smart To-Do List Manager with Python, MongoDB & Kivy (Part 4: Filter & Search)

Implement search and filter capabilities in your Kivy application.

Building a Smart To-Do List Manager with Python, MongoDB & Kivy (Part 4: Filter & Search)

Hello everybody and welcome (back) to my blog!

This is the part 4 (and the last article) of our series about building a smart To-Do list Manager with Python, MongoDB and Kivy! xD

The search & filter features are crucial functionalities of an application and today, we are going to look at how it could be integrated into our application.

This article could serve as a base even for someone that has not been building this application along since the first article. You just need to grasp the notions and make them your own.

Without further ado, let’s get into it.

Search Feature

1- The search related variables

  • In a previous article, while building the main screen, we created a research bar as a TextInput variable and a simple string variable to hold the value typed by the user.

      self.search_query = ""
      self.search_input = TextInput(hint_text="Search tasks", size_hint_x=0.5, multiline=False)
    

2- The binding operation

  • The TextInput variable has been bound to a function that will ensure to display the tasks according to what the user has written in the research bar. The bind operation will use the text property, ensuring that the research function is triggered whenever the text inside the input changes.

      self.search_input.bind(text=self.on_search_text)
    

    When the bind operation is performed, Kivy automatically monitors the text property of self.search_input and stores the text entered by the user in a variable that we will call value . In our implementation, we use this value to update our self.search_query variable and refresh the display for each task tab. Each time the user types or edits the text, the on_search_text function is called. This function receives two parameters:

    • instance: The TextInput widget that triggered the event.

    • value: The current text entered by the user.

    def on_search_text(self, instance, value):
        # Trigger a task refresh for each tab
        self.search_query = value
        self.all_tab.filter_tasks()
        self.pending_tab.filter_tasks()
        self.completed_tab.filter_tasks()

3- The logic behind the search operation?

  • As mentioned in earlier articles, the task tabs are represented by the TaskTab class. Each tab displays tasks of a specific category (e.g., all tasks, pending tasks, completed tasks). The filter_tasks method in the TaskTab class is responsible for filtering and displaying tasks based on the search query.

    The TaskTab class is initialised with an instance of TaskListScreen, which represents the main screen. This allows the TaskTab class to access the search_query stored in TaskListScreen.

    Here’s how everything fits together:

    • The user types in the search bar (TextInput).

    • The text property changes, triggering on_search_text.

    • on_search_text updates search_query and calls filter_tasks for each tab.

    • Each tab filters tasks using the search query and updates its display.

Here’s how the filtering logic works:

    def filter_tasks(self):
        # Filter tasks based on the search query
        filtered_tasks = [task for task in self.tasks if 
                         self.task_list_screen.search_query.lower() in task['name'].lower() or
                         self.task_list_screen.search_query.lower() in task['description'].lower()]
        self.task_list_layout.clear_widgets()
        # Add tasks to the layout
        for task in filtered_tasks:
            task_frame = TaskFrame(task, self.task_list_screen)
            self.task_list_layout.add_widget(task_frame)
  • For reference (and context), the TaskTab class is created with an instance of the TaskListScreen (which is the class representing the main screen).

  • self.task is a dictionary that contains the tasks retrieved from the database.

  • We iterate over self.tasks and we filter out the task by simply keeping those whose name or description contains the searched word.

  • After clearing out the layout, we create the tasks objects to display using the TaskFrame class.

    While our example filters tasks based on the task's name and description, you can customize the filtering logic to suit your needs. For instance, you could filter based on additional fields or use advanced search criteria.

    Additionally, the task display uses the TaskFrame class to create the visual representation of each task. For more details on this class, refer to Part 2 of the tutorial.

You should be able to perform a search!

Now, something a little bit more complex: the filter feature.

Filter Feature

The FilterPopup class is a user interface component designed to facilitate task filtering based on multiple criteria such as date, status, category, and priority. It is built using the Popup widget from Kivy, providing a modal window where users can refine their task searches. This class ensures seamless interaction between the popup and the main task management screen by leveraging a callback function to pass filtered data.

Let’s begin by defining the layout and the basic functioning of the items displayed on the screen.

Filter Popup Layout

1.Initialisation

  • The constructor accepts two main parameters:

    • on_filter_applied: A callback function provided by the main screen to handle and display the filtered data.

    • dbname: A reference to the database for tasks.

  • The popup is titled "Filter Tasks" and uses a size hint of 80% of the screen for width and height.

  • A dictionary, selected_filters, stores the user's selected criteria, initialised with default values.

class FilterPopup(Popup):
    def __init__(self, on_filter_applied, dbname, **kwargs):
        super(FilterPopup, self).__init__(**kwargs)
        #self.task_list_screen = task_list_screen
        # on_filter_applied is actually a Callback function to handle filtered data and display them
        self.on_filter_applied = on_filter_applied
        self.dbname = dbname
        self.title = 'Filter Tasks'
        self.size_hint = (0.8, 0.8)
        self.selected_filters = {
            'date': None,
            'status': "",
            'category': "",
            'priority': ""
        }

2. Layout Design

The popup uses a structured layout to present filtering options clearly:

  • Date Filter:

    • Includes a label and a button to open a calendar for date selection.

    • The selected date is displayed on the label once chosen.

  • Status Filter:

    • A Spinner widget allows the user to choose between statuses such as "to-do," "in progress," and "completed."
  • Category Filter:

    • Another Spinner provides categories like "Work/Professional," "Personal," and "Health/Fitness."
  • Priority Filter:

    • A final Spinner offers priority levels like "high," "medium," and "low."
  • Filter Button:

    • A button triggers the filtering process and invokes the callback function.

The layout ensures a clean, user-friendly interface with proper spacing and alignment.

# Create the form layout
form_layout = GridLayout(cols=2, padding=10, spacing=10)
# Date Filter
date_layout = BoxLayout(orientation='horizontal', size_hint_y=None, height=50)
# Add a label for the date on the left
self.date_label = Label(text="Select Date:", size_hint=(0.4, None), height=50)  # Adjust size_hint if needed
date_layout.add_widget(self.date_label)
date_picker_btn = Button(text="Open Calendar", size_hint=(0.6, None), height=50)
date_picker_btn.bind(on_release=self.open_calendar)
date_layout.add_widget(date_picker_btn)
form_layout.add_widget(date_layout)

# Status Filter
status_layout = BoxLayout(orientation='horizontal', size_hint_y=None, height=50)
status_layout.add_widget(Label(text="Status:",size_hint=(0.4, None), height=50))
self.status_spinner = Spinner(
     text="All",
     values=("all", "to-do", "in progress", "completed"),
     size_hint=(0.6, None),
     height=50
)
status_layout.add_widget(self.status_spinner)
form_layout.add_widget(status_layout)

# Category Filter
category_layout = BoxLayout(orientation='horizontal', size_hint_y=None, height=50)
category_layout.add_widget(Label(text="Category:",size_hint=(0.4, None), height=50))
#form_layout.add_widget(Label(text="Category:",size_hint_x=0.3))
self.category_spinner = Spinner(
    text="All",
    values=("All", "Work/Professional", "Personal", "Home", "Education/Learning", "Health/Fitness", "Social", "Travel", "Creativity/Projects", "Goals and Long-Term Plans"),
    size_hint=(0.6, None),
    height=50
)
category_layout.add_widget(self.category_spinner)
form_layout.add_widget(category_layout)

# Priority Filter
priority_layout = BoxLayout(orientation='horizontal', size_hint_y=None, height=50)
priority_layout.add_widget(Label(text="Priority:",size_hint=(0.4, None), height=50))
self.priority_spinner = Spinner(
    text="All",
    values=("All", "high", "medium", "low"),
    size_hint=(0.6, None),
    height=50
)
priority_layout.add_widget(self.priority_spinner)
form_layout.add_widget(priority_layout)

3. Calendar Integration

The open_calendar method displays a date picker (MDDatePicker) for selecting a specific date. Once the user selects a date, the set_date method updates the corresponding label with the chosen value in the format YYYY-MM-DD.

def open_calendar(self, instance):
    date_dialog = MDDatePicker()
    date_dialog.bind(on_save=self.set_date)
    date_dialog.open()

def set_date(self, instance, value, *args):
    self.date_label.text = f"{value.strftime('%Y-%m-%d')}"

Filtering Logic

When the user clicks the "Apply Filter" button, the apply_filter method is triggered:

  • It gathers the selected filters from the form.

  • Passes the data to the on_filter_applied callback for processing and display.

  • Closes the popup.

Focus on Callback functions

Callback functions can seem tricky at first, but they’re a powerful concept in programming. By the end of this section, you’ll understand what they are, how they work, and how we use them in the Filter Popup Logic.

What are Callback functions?

A callback function is a function that is passed as an argument to another function. The receiving function can then call (or "callback") the function at the right time.

Callbacks are especially useful when you need to:

  • Perform an action after something else is completed.

  • Decouple logic (separate responsibilities in code).

  • Make code reusable and flexible.

Basic Example of a callback function

Here’s a simple example to illustrate:

def greet(name):
    print(f"Hello, {name}!")

def run_callback(callback_function, value):
    # The callback function is called here
    callback_function(value)

# Call run_callback and pass 'greet' as the callback
run_callback(greet, "Prunella")

What happens:

  1. run_callback accepts a function (callback_function) and a value (value).

  2. Inside run_callback, it calls greet("Prunella").

  3. Output: Hello, Prunella!

Real-World Use Case: The Filter Popup

In our Filter Popup, we use a callback function to apply a filter and display the filtered results. Let’s break it down step by step.

1. Why Do We Need a Callback?

The FilterPopup class handles the user’s input (e.g., filter criteria). However, the actual task filtering logic belongs to the TaskListScreen class. Instead of tightly coupling the two classes, we pass a callback function from TaskListScreen to FilterPopup.

This way:

  • The FilterPopup doesn’t need to know anything about the task filtering logic.

  • The TaskListScreen can handle the filtered results directly.


2. How It Works

Here’s how the callback logic connects the Filter Popup and TaskListScreen:

Step1: Create the Callback in TaskListScreen class

In the TaskListScreen class, define the callback function that processes filtered data:

def display_filtered_data(self, filtered_data):
    # This function is called when the FilterPopup applies filters
    self.all_tab.filter_feature(filtered_data)
    self.pending_tab.filter_feature(filtered_data)
    self.completed_tab.filter_feature(filtered_data)

This function:

  • Accepts filtered_data as a parameter (passed from the popup).

  • Updates the display for all tabs.

The filter_feature method is a method in TaskTab class which like the filter_tasks will display the tasks according to the filters imposed by the user.

This function should look like this:

def filter_feature(self, filtered_data):
    #filtered_data is a dictionary that represents the task that the user will see
    #according to the filters applied
    filtered_tasks = [task for task in self.tasks if task in filtered_data]
    self.task_list_layout.clear_widgets()
    # Add tasks to the layout
    for task in filtered_tasks:
    task_frame = TaskFrame(task, self.task_list_screen)
    self.task_list_layout.add_widget(task_frame)

Step 2: Pass the Callback to FilterPopup

When creating the FilterPopup instance in TaskListScreen class, pass display_filtered_data as the callback:

def show_filter_popup(self):
    popup = FilterPopup(self.display_filtered_data, self.dbname)
    popup.open()

Here, self.display_filtered_data is passed to FilterPopup.

Also, this function is triggered when the user clicks on the filter button on the main screen, as written in the code below:

filter_button = Button(text="Filter Tasks", size_hint_x=0.2, height=50)
filter_button.bind(on_press=self.show_filter_popup)
#don't forget to actually add the button to your screen layout

Step 3: Trigger the Callback in FilterPopup

Inside the FilterPopup, a button is created for the user to apply the chosen filters:

# Add Filter Button
filter_btn = Button(text="Apply Filter", size_hint=(1, None), background_color=(0, 0, 1, 1), height=50)
filter_btn.bind(on_press=self.apply_filter)

As stated above, a callback function provided by the main screen to handle and display the filtered data.We call the callback when the user applies the filters using the apply_filter method :

#Note: gcommands is just a file imported and used as a module
def apply_filter(self, instance):
    # We retrieve the values from the form
    try:
        self.selected_filters['date'] = gcommands.datetime.strptime(self.date_label.text, '%Y-%m-%d')
    except:
        self.selected_filters['date'] =  None
    self.selected_filters['status'] = self.status_spinner.text
    self.selected_filters['category'] = self.category_spinner.text
    self.selected_filters['priority'] = self.priority_spinner.text

    #We retrieve the data from the database
    filtered_data = gcommands.filter_func(self.selected_filters['date'], self.selected_filters['status'], self.selected_filters['category'], self.selected_filters['priority'], self.dbname)
    # Trigger the callback with the selected filter values to display them
    self.on_filter_applied(filtered_data)
    # Dismiss the popup
    self.dismiss()
  • First, we have to retrieve the values selected by the user in the form and pass them down to our variable selected_filters:

      #Note: gcommands is just a file imported and used as a module
      try:
          self.selected_filters['date'] = gcommands.datetime.strptime(self.date_label.text, '%Y-%m-%d')
      except:
          self.selected_filters['date'] =  None
          self.selected_filters['status'] = self.status_spinner.text
          self.selected_filters['category'] = self.category_spinner.text
          self.selected_filters['priority'] = self.priority_spinner.text
    
  • Then, using selected_filters, we call the database management function filter_func to get the right tasks to display. Let’s go through this function using the comments in the code (it will be less confusing I think ;) ).

      #those are the variables selected by the user and an instance of the database
      def filter_func(selected_date, selected_status, selected_category, selected_priority, dbname):
          #Let's get the right collection to work with in the database
          tasks_collection = dbname["tasks"]
          #We create a dictionary to keep the conditions aka the values to look for in the collection
          query_conditions = dict()
          #Connected_user is used as a session variable to identify the user
          query_conditions["creator_name"] = connected_user
          #If the user selected a date,
          # it filters documents where the deadline field falls within a specific day
          # (starting at selected_date and ending just before the next day).
          if selected_date != None:
              query_conditions["deadline"] = {
                      "$gte": selected_date,
                      "$lt": selected_date + timedelta(days=1) - timedelta(microseconds=1)
                  }
          #We check if the other fields/filter values are not defaut (All) before
          #adding them to query_conditions
          if selected_status.lower() != "all":
              query_conditions["status"] = selected_status
          if selected_priority.lower() != "all":
              query_conditions["priority"] = selected_priority
          if selected_category.lower() != "all":
              query_conditions["category"] = selected_category
          #We verify that the conditions are met
          if query_conditions:
          # Execute the query
              documents = list(tasks_collection.find(query_conditions, {"_id": 0}))
              #print(documents)
          return documents
    
  • Finally, we can trigger the callback function (on_filter_applied) with the values retrieved the using filter_func method


3. Callback Flow Visualisation

Just to make sure you understood, let’s recap the callback flow.

The user interacts with the popup and selects filters.

  • The TaskListScreen creates an instance of FilterPopup, passing its own filtering method as the on_filter_applied callback.

  • The user opens the popup, selects filter criteria, and clicks "Apply Filter."

  • The popup calls apply_filter, which processes the filters and generates filtered_data

  • The popup gathers the criteria, calls the callback, and sends the filtered data back to the main screen: it triggers the callback (self.on_filter_applied), passing filtered_data back to TaskListScreen.

  • The main screen (TaskListScreen) refreshes the task list to display only the tasks matching the selected filters.

Aaaaaaand, we are all set! Your task management app is now not only functional but also user-friendly and dynamic. The filter & search features ensure that users can efficiently find and manage their tasks based on specific needs.

Conclusion

Over the course of this series, we’ve journeyed through building a feature-rich task management application. From designing the main screen and handling database operations to implementing advanced features like filtering and searching, we've covered a comprehensive set of tools and techniques. While this series concludes here, the journey doesn’t have to stop. You could add notifications, task-sharing capabilities, or even integrate the app with external calendars like Google Calendar. These additions would make your app even more robust and versatile.

I want to thank you for joining me in this adventure.I hope this series has been as rewarding for you to follow as it was for me to create. Your dedication to building this app speaks volumes about your commitment to learning and growth.

You can find the complete code for this project here. I would love to hear your feedbacks and see how you’ve expanded on the project!

To really conclude, let’s all remember that every great app starts with a single line of code. Let’s keep experimenting, building, and most importantly, keep learning. Cheers to more successful projects!🥂🥂