Cat

Kawai.Focus - приложение для фокусировки внимания (часть 3)

Данная статья посвящена:

  • Продолжению реализации минимального функционала таймера Pomodoro;
  • Работе с фреймворком Kivy;
  • Использованию kv файлов для работы с интерфейсом;
  • Добавлению звука для таймера;
  • Улучшению кода.

Дополнительные материалы

Icon Link

Реклама

Icon Link
Kawai.Focus Arduinum628 27 Март 2025 Просмотров: 54

Вступление

Всем доброго дня! В предыдущей статье Kawai.Focus - приложение для фокусировки внимания (часть 2) я начал работу над функционалом таймера Pomodoro. Был реализован функционал старта таймера, паузы и полной отмены таймера. Также была создана блок-схема пользовательского пути для приложения.

Сегодня я продолжу писать таймер Pomodoro, улучшу его код и перенесу работу с интерфейсом приложения в kv файл. Также подключу звук срабатывания таймера (в настоящий момент в таймере нет звука - это нужно исправить). Заварите себе чай и приготовьте вкусняшки — время продолжить разбираться с "помидором"! =)

Код

json

Чтобы избежать дублирования строк в логах ошибок и таймера, я поместил эти данные в файлы json. Для этого я создал два файла: errors.json и timer.json. В errors.json хранятся сообщения об ошибках, а в timer.json содержится всё, что касается таймера (например, название аудиофайла).

Содержимое errors.json:

{
    "no_time": "Не указано время таймера!",
    "negative_time": "Время таймера не может быть отрицательным!",
    "ss_mm_big": "Секунды и минуты не могут быть больше 59!",
    "hh_big": "Часы не могут быть больше 23!"
}

Содержимое timer.json:

{
    "sound_timer": "alarm-beep.mp3",
    "zero_time": "00:00:00",
    "custom_time": "00:00:12"
}

kawai_focus/utils/data_json.py

Для чтения json-файлов я написал класс в файле data_json.py для работы с этим форматом:

import json

from kawai_focus.main import Logger


class ReadJson:
    """Класс для чтения json файла"""

    def __init__(self, file_name: str):
        self.file_name = file_name
        self.json_data = self.read_json_as_dict()

    def read_json_as_dict(self) -> dict:
        """Метод для чтения json файла как dict"""

        try:
            file_path = f'json/{self.file_name}'
            with open(file=file_path, mode='r') as file:
                return json.load(file)
        except (FileNotFoundError, json.decoder.JSONDecodeError) as err:
            Logger.error(f'Logger: {err.__class__.__name__}: {err}')

    def get_text(self, name: str) -> str:
        """Метод для возврата текста из файла json по ключу name"""
        
        text = self.json_data.get(name)
        
        if text:
            if isinstance(text, list):
                text = ''.join(text)
                return text
            return text


read_json_err = ReadJson(file_name='errors.json')
read_json_timer = ReadJson(file_name='timer.json')

Основная информация о классе ReadJson:

  • read_json_as_dict() читает JSON-файл как dict:
    • file_path = f'json/{self.file_name}' путь до файла json;
    • with open(file=file_path, mode='r') as file: открывает файл в режиме чтения, а после прочтения закрывает его;
    • return json.load(file) читает файл как dict и возвращает его;
    • except (FileNotFoundError, json.decoder.JSONDecodeError) as err: отлавливает два исключения:
      • FileNotFoundError — файл не найден;
      • json.decoder.JSONDecodeError — файл не является корректным JSON.
  • get_text() возвращает данные по ключу:
    • Если значение — список, строки объединяются в одну при помощи ''.join(text);
    • Если это строка, возвращает её в исходном виде.
  • self.json_data = self.read_json_as_dict() хранит dict с данными после чтения json, при инициализации класса;

Экземпляры класса ReadJson:

  • read_json_err = ReadJson(file_name='errors.json') — хранит данные об ошибках для импорта;
  • read_json_timer = ReadJson(file_name='timer.json') — хранит информацию о таймере для импорта.

kawai_focus/utils/utils.py

Иногда бывает так, что разработчик пишет код, который нормально работает, но его нужно улучшить или модернизировать. Это может происходить по разным причинам, но чаще всего из-за неверно выбранного способа реализации или необходимости оптимизировать ресурсы.

Старый код таймера

import re
from typing import Generator
import logging

from kivy.logger import Logger


Logger.setLevel(logging.DEBUG)


def custom_timer(timer_str: str) -> Generator[str, None, None]:
    """Функция отсчитывает время, установленное для таймера в формате 'XhYmZs'."""
    
    try:
        match = re.match(r'(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?', timer_str)
        
        if not match:
            raise ValueError('Неверный формат времени, используйте "XhYmZs"!')
    hours, minutes, seconds = match.groups()
    total_seconds = (int(hours or 0) * 3600) + (int(minutes or 0) * 60) + (int(seconds or 0))
    
    if total_seconds  0:
        raise ValueError('Не указано время таймера!')
    
    for remaining in range(total_seconds, -1, -1):
        yield f"{remaining // 3600:02d}:{(remaining % 3600) // 60:02d}:{remaining % 60:02d}"
    except ValueError as err:
        Logger.error(f'{err.__class__.__name__}: {err}')

Этот код рабочий: генераторная функция custom_timer() возвращает генератор, который выдаёт текущее значение времени таймера. Более подробно о работе данного таймера можно узнать в моей предыдущей статье, в разделе kawai.focus/utils.py.

Однако в коде присутствуют лишние операции со строками:

  • match = re.match(r'(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?', timer_str) — сопоставление регулярного выражения со строкой из timer_str;
  • raise ValueError('Неверный формат времени, используйте "XhYmZs"!') — выбрасывание исключения, если формат времени не соответствует XhYmZs;
  • hours, minutes, seconds = match.groups() — извлечение значений часов, минут и секунд.

Эти операции можно оптимизировать, если работать с числами на входе, минуя преобразования строк.

Обновлённый код таймера

from typing import Generator

from kawai_focus.main import Logger
from kawai_focus.utils.data_json import read_json_err


def custom_timer(hh: int=0, mm: int=0, ss: int=0) -> Generator[str, None, None]:
    """Функция отсчитывает время, установленное для таймера в формате 
    'hh:mm:ss'. Возвращает генератор, который возвращает текущее время
    в формате 'hh:mm:ss'."""

    try:
        if ss == 0 and mm == 0 and hh == 0:
            raise ValueError(read_json_err.get_text('no_time'))
        
        if ss < 0 or mm < 0 or hh < 0:
            raise ValueError(read_json_err.get_text('negative_time'))

        if ss > 59 or mm > 59:
            raise ValueError(read_json_err.get_text('ss_mm_big'))
        
        if hh > 23:
            raise ValueError(read_json_err.get_text('hh_big'))

        total_seconds = (int(hh or 0) * 3600) + (int(mm or 0) * 60) + (int(ss or 0))

        for remaining in range(total_seconds, -1, -1):
            yield f"{remaining // 3600:02d}:{(remaining % 3600) // 60:02d}:{remaining % 60:02d}"
    except (TypeError, ValueError) as err:
        Logger.error(f'Logger: {err.__class__.__name__}: {err}')

Изменения в коде:

  • from kawai_focus.main import Logger изменился импорт логгера теперь он находится в main.py;
  • from kawai_focus.utils.data_json import read_json_err импорт словаря с ошибками;
  • Обработка отсутствующего времени (ss 0 и mm 0 и hh 0): Если все три значения равны нулю, выбрасывается ошибка ValueError со строкой Не указано время таймера!, полученной через read_json_err.get_text();
  • Проверка отрицательных значений (ss < 0 или mm < 0 или hh < 0): Если одно из значений отрицательное, выбрасывается ошибка ValueError с текстом Время таймера не может быть отрицательным!;
  • Проверка недопустимых значений минут и секунд (ss > 59 или mm > 59): Если значение секунд или минут превышает 59, выбрасывается ошибка с текстом Секунды и минуты не могут быть больше 59!;
  • Ограничение на часы (hh > 23): Если часы превышают 23, выбрасывается ошибка ValueError со строкой Часы не могут быть больше 23!;
  • Математическое преобразование (total_seconds = (int(hh or 0) * 3600) + (int(mm or 0) * 60) + (int(ss or 0))): Вычисление общего количества секунд остаётся неизменным. Это решение оптимально в данном контексте, так как математика здесь проще и быстрее, чем использование timedelta или другой функции из Python. Это связано с тем, что математические операции выполняются на низком уровне и являются крайне оптимизированными, в то время как timedelta — это объект с методами и дополнительной логикой, что добавляет накладные расходы.

kawai_focus/screens/timer_screen.py

Перенос кода для интерфейса

"Портянка" из кода в классе TimerScreen, которая отвечает за интерфейс экрана:

class TimerScreen(Screen):
    """Экран таймера"""
    
    # Другой код
    
    self.layout = FloatLayout()
    
    # Добавляем кнопку "Старт"
    self.start_button = Button(
        text='Старт', 
        size_hint=(None, None), 
        height=40, 
        width=100, 
        pos_hint={
            'center_x': 0.3, 
            'center_y': 0.5
        }
    )
    
    self.start_button.bind(on_release=self.start_timer)
    self.layout.add_widget(self.start_button)
    
    # Добавляем кнопку "Пауза"
    self.pause_button = Button(
        text='Пауза', 
        size_hint=(None, None), 
        height=40, 
        width=100, 
        pos_hint={
            'center_x': 0.5, 
            'center_y': 0.5
        }
    )
    
    self.pause_button.bind(on_release=self.pause_timer)
    self.layout.add_widget(self.pause_button)
    
    # Добавляем кнопку "Стоп"
    self.stop_button = Button(
        text='Стоп', 
        size_hint=(None, None), 
        height=40, 
        width=100, 
        pos_hint={
            'center_x': 0.7, 
            'center_y': 0.5
        }
    )
    
    self.stop_button.bind(on_release=self.stop_timer)
    self.layout.add_widget(self.stop_button)
    
    # Добавляем Label для отображения времени
    self.time_label = Label(
        text='00:00:10', 
        size_hint=(None, None), 
        pos_hint={
            'center_x': 0.5, 
            'center_y': 0.6
        }
    )
    
    self.layout.add_widget(self.time_label)
    self.add_widget(self.layout)
    
    # Другой код

Этот код отвечает за создание кнопок, меток, добавление их на экран и указание их расположения.

Теперь весь код интерфейса был перенесён в отдельный файл timer_screen.kv:

#:kivy 2.3.1

<TimerScreen>:
    FloatLayout:
        # Кнопка "Старт"
        Button:
            text: 'Старт'
            size_hint: None, None
            height: 40
            width: 100
            pos_hint: {'center_x': 0.3, 'center_y': 0.5}
            on_release: root.start_timer(self)
        
        # Кнопка "Пауза"
        Button:
            text: 'Пауза'
            size_hint: None, None
            height: 40
            width: 100
            pos_hint: {'center_x': 0.5, 'center_y': 0.5}
            on_release: root.pause_timer(self)
        
        # Кнопка "Стоп"
        Button:
            text: 'Стоп'
            size_hint: None, None
            height: 40
            width: 100
            pos_hint: {'center_x': 0.7, 'center_y': 0.5}
            on_release: root.stop_timer(self)
        
        # Label для отображения времени
        Label:
            id: time_label
            text: '00:00:12'
            size_hint: None, None
            pos_hint: {'center_x': 0.5, 'center_y': 0.6}

Объяснение содержимого timer_screen.kv:

  • #:kivy 2.3.1 — указывает версию Kivy;
  • <TimerScreen>: — определяет экран таймера;
  • FloatLayout: — задаёт вид компоновки (layout);
  • Button: — описание кнопки:
    • text: 'Старт' — текст на кнопке;
    • size_hint: None, None — отключение автоматического масштабирования;
    • height: 40 — высота кнопки в пикселях;
    • width: 100 — ширина кнопки в пикселях;
    • pos_hint: {'center_x': 0.3, 'center_y': 0.5} — позиция кнопки в относительных единицах;
    • on_release: root.start_timer(self) — событие, вызываемое при нажатии на кнопку "Старт";
  • Label: — описание метки:
    • id: time_label — идентификатор для взаимодействия с меткой из кода;
    • Остальные свойства аналогичны кнопкам.

Преимущества использования .kv файлов:

  • Уменьшение количества строк кода в Python;
  • Более читаемая структура описания пользовательского интерфейса;
  • Лёгкость редактирования и настройки элементов.

kawai_focus/main.py

Код загрузки .kv файла:

# Другой код
from kivy.lang import Builder
from kivy.logger import Logger

import logging

Logger.setLevel(logging.DEBUG)


class KawaiFocusApp(App):
    """Класс для создания приложения"""
    
    title = 'Kawai.Focus'
    
    def build(self):
        # Загрузка kv файла
        Builder.load_file('kv/timer_screen.kv')
        
        # Другой код

Пояснения к коду загрузки .kv файла:

  • from kivy.lang import Builder — импорт класса Builder, который загружает и интерпретирует .kv-файлы;
  • Builder.load_file('kv/timer_screen.kv') — загружает .kv-файл, парсит его содержимое и создаёт соответствующие виджеты.

Подключение звука

Таймер должен издавать звук после завершения отсчёта времени. Для этого я скачал файл alarm-beep.mp3 и поместил его в папку sounds проекта. Затем был написан код, который отвечает за загрузку, воспроизведение и остановку звука таймера.

Код для работы со звуком в timer_screen.py:

# Другой код
from kivy.core.audio import SoundLoader


class TimerScreen(Screen):
    """Экран таймера"""
    
    sound_timer_name = read_json_timer.get_text('sound_timer')
    path_file = f'sounds/{sound_timer_name}'
    sound = SoundLoader.load(path_file)
    
    def __init__(self, **kwargs):
        super(TimerScreen, self).__init__(**kwargs)
        self.zero_time = read_json_timer.get_text('zero_time')
        self.timer_generator = None
        self.paused = False
        self.remaining_time = None
        self.sound_stop_event = None  # событие для остановки звука
    
    # Другой код
    def stop_timer(self, instance) -> None:
        """Метод для остановки таймера"""
        
        # Остановка обновления времени
        Clock.unschedule(self.update_time)
        self.paused = False
        
        # TODO: временное решение, в время будет задаваться в интерфейсе
        self.remaining_time = read_json_timer.get_text('custom_time')
        self.ids.time_label.text = self.remaining_time
        self.sound.stop()
        
        if self.sound_stop_event:
            Clock.unschedule(self.sound_stop_event)
            self.sound_stop_event = None
    
    def play_sound(self, dt) -> None:
        """Метод для воспроизведения звука"""
        
        self.sound.play()
        # Планируем остановку звука через 20 секунд
        self.sound_stop_event = Clock.schedule_once(lambda dt: self.sound.stop(), 20)
    
    # Другой код

Описание кода для звука:

  • from kivy.core.audio import SoundLoader — импорт класса SoundLoader, который отвечает за работу со звуком в Kivy;
  • sound = SoundLoader.load(path_file) — загружает звуковой файл и создаёт объект для управления им;
  • self.sound_stop_event = None — используется для хранения события, отвечающего за автоматическую остановку звука;
  • stop_timer() — метод для остановки таймера и звука:
    • self.ids.time_label.text = self.remaining_time — обращение через id к тексту метки time_label (определённой в .kv-файле);
    • self.sound.stop() — завершает воспроизведение звука;
    • if self.sound_stop_event: — если запланировано событие остановки, выполняется его отмена с помощью Clock.unschedule(self.sound_stop_event), после чего self.sound_stop_event сбрасывается в None;
  • play_sound() — метод для воспроизведения звука:
    • self.sound.play() — начинает воспроизведение звука;
    • self.sound_stop_event = Clock.schedule_once(lambda dt: self.sound.stop(), 20) — планирует автоматическую остановку звука через 20 секунд.

Код timer_screen.py полностью:

from kivy.uix.screenmanager import Screen
from kivy.clock import Clock
from kivy.core.audio import SoundLoader
from kawai_focus.utils.utils import custom_timer
from kawai_focus.utils.data_json import read_json_timer


class TimerScreen(Screen):
    """Экран таймера"""

    sound_timer_name = read_json_timer.get_text("sound_timer")
    path_file = "sounds/{sound_timer_name}"
    sound = SoundLoader.load(path_file)

    def __init__(self, **kwargs):
        super(TimerScreen, self).__init__(**kwargs)
        # Переменные для управления таймером
        self.zero_time = read_json_timer.get_text("zero_time")
        self.timer_generator = None
        self.paused = False
        self.remaining_time = None
        self.sound_stop_event = None

    def start_timer(self, instance) -> None:
        """Метод для запуска таймера"""
        if self.paused:
            self.paused = False
        else:
            # Инициализация генератора таймера
            self.timer_generator = custom_timer(ss=12)  # Установите необходимое время
            self.remaining_time = next(self.timer_generator, self.zero_time)
            # Запуск обновления времени каждую секунду
            Clock.schedule_interval(self.update_time, 1)

    def pause_timer(self, instance) -> None:
        """Метод для паузы таймера"""
        if not self.paused:
            self.paused = True
            # Остановка обновления времени
            Clock.unschedule(self.update_time)

    def stop_timer(self, instance) -> None:
        """Метод для остановки таймера"""
        # Остановка обновления времени
        Clock.unschedule(self.update_time)
        self.paused = False
        # TODO: временное решение, время будет задаваться в интерфейсе
        self.remaining_time = read_json_timer.get_text("custom_time")
        self.ids.time_label.text = self.remaining_time
        self.sound.stop()
        if self.sound_stop_event:
            Clock.unschedule(self.sound_stop_event)
            self.sound_stop_event = None

    def play_sound(self, dt) -> None:
        """Метод для воспроизведения звука"""
        self.sound.play()
        # Планируем остановку звука через 20 секунд
        self.sound_stop_event = Clock.schedule_once(lambda dt: self.sound.stop(), 20)

    def update_time(self, dt) -> None:
        """Метод для обновления времени на экране"""
        if self.paused:
            return
        # Получение следующего значения времени из генератора
        self.remaining_time = next(self.timer_generator, self.zero_time)
        self.ids.time_label.text = self.remaining_time
        if self.remaining_time == self.zero_time:
            # Остановка обновления времени
            Clock.unschedule(self.update_time)
            Clock.schedule_once(self.play_sound)

Обновлённая структура проекта

├── kawai-focus
|   ├── screens
|       └── __init__.py
|       └── timer_screen.kv
|   ├── utils
|       └── __init__.py
|       └── data_json.py
|       └── utils.py
│   └── __init__.py
│   └── main.py
├── json
|   └── errors.json
|   └── timer.json
├── sounds
|   └── alarm-beep.mp3
├── kv
|   └── timer_screen.kv
├── pyproject.toml
├── poetry.lock
├── README.md
├── LICENSE
├── .gitignore

Запуск приложения

Я запускаю своё приложение с помощью Poetry в терминале:

poetry run kawai-focus

Видео-демонстрация таймера со звуком на Rutube:

Видео-демонстрация таймера со звуком на YouTube:


Анонс на следующие статьи

Следующая моя статья выйдет 17.04.25. В ней я добавлю экран конструктора таймера, где будет реализован переход с одного экрана на другой. Также появится возможность задавать длительность и другие параметры таймера. Для хранения этих данных я подключу базу данных SQLite3 и начну сохранять настройки таймера.

У меня появилась подработка на написании IT-статей. Сама статья: DIY-проект: гусеничная платформа с ИК-управлением на Arduino. Первая публикация на Habr уже состоялась! 😊

Читайте продолжение — будет интересно!


Заключение

  1. Написан код для работы с JSON;
  2. Улучшен и оптимизирован код функции custom_timer();
  3. Перенесён код интерфейса UI из timer_screen.py в .kv файл;
  4. Подключен звук для таймера;
  5. Сообщения об ошибках и данные таймера вынесены в JSON;
  6. Логгер (Logger) вынесен в файл main.py.

Ссылки к статье

Автор

    Нет комментариев

    Реклама