diff --git a/.gitignore b/.gitignore index 5cf82a4..75c1f56 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ -.vscode -venv \ No newline at end of file +__pycache__ +venv +tmp +settings.json +.* +output +logs \ No newline at end of file diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..7b762c4 --- /dev/null +++ b/changelog.md @@ -0,0 +1,2 @@ +## v2.0.0 - 2024-04-02 +- First release of EyesGuard for Windows written in Python \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..fcf58f4 --- /dev/null +++ b/readme.md @@ -0,0 +1,22 @@ +## About EyesGuard +Program EyesGuard helps you to keep vision in order. +It reminds about necessity to have a break during long work and informs about the start and the end of the break. + +EyesGuard is distributed under GPL v3 licence. By for now it is tested with Python 3.11 and Windows 10. + +You can find more info about EyesGuard at [www.eyesguard.ru](https://eyesguard.ru). + +Command to start app while developing: +``` +python ./src/main.py +``` + +Command for running tests: +``` +python -X utf8 -m pytest .\tests\ +``` + +Command for building .exe: +``` +auto-py-to-exe +``` \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f2acbf2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +customtkinter==5.2.0 +loguru==0.7.2 +pydantic==2.1.1 +pystray==0.19.4 +pytest==7.4.0 \ No newline at end of file diff --git a/res/__init__.py b/res/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/res/img/break_wnd_bg1.png b/res/img/break_wnd_bg1.png new file mode 100644 index 0000000..c850a9e Binary files /dev/null and b/res/img/break_wnd_bg1.png differ diff --git a/res/img/check_mark.png b/res/img/check_mark.png new file mode 100644 index 0000000..7c9113d Binary files /dev/null and b/res/img/check_mark.png differ diff --git a/res/img/clock.png b/res/img/clock.png new file mode 100644 index 0000000..bffaf93 Binary files /dev/null and b/res/img/clock.png differ diff --git a/res/img/eyes_protection_off.png b/res/img/eyes_protection_off.png new file mode 100644 index 0000000..c4c6979 Binary files /dev/null and b/res/img/eyes_protection_off.png differ diff --git a/res/img/eyes_with_protection.ico b/res/img/eyes_with_protection.ico new file mode 100644 index 0000000..5110412 Binary files /dev/null and b/res/img/eyes_with_protection.ico differ diff --git a/res/img/eyes_with_protection.png b/res/img/eyes_with_protection.png new file mode 100644 index 0000000..445a97c Binary files /dev/null and b/res/img/eyes_with_protection.png differ diff --git a/res/img/eyes_without_protection.png b/res/img/eyes_without_protection.png new file mode 100644 index 0000000..e9633fe Binary files /dev/null and b/res/img/eyes_without_protection.png differ diff --git a/res/img/gear.png b/res/img/gear.png new file mode 100644 index 0000000..7fed057 Binary files /dev/null and b/res/img/gear.png differ diff --git a/res/img/info.png b/res/img/info.png new file mode 100644 index 0000000..f1e85c6 Binary files /dev/null and b/res/img/info.png differ diff --git a/src/controller.py b/src/controller.py new file mode 100644 index 0000000..5a7c560 --- /dev/null +++ b/src/controller.py @@ -0,0 +1,48 @@ +from threading import Thread + +from logger import logger +from model import Model +from settings import UserSettingsData +from states import StepType + + +class Controller: + """Class for time and break control""" + + def __init__(self): + logger.trace("Controller: object was created") + + def set_model(self, model: Model): + """Assigning model to controller""" + logger.trace("Controller: set_model") + self.model = model + self.thread_alg = None + + def start(self): + """Start controller in separated thread""" + if self.thread_alg is None: + self.thread_alg = Thread(target=self.main_loop) + self.thread_alg.start() + + def stop(self): + pass + + def main_loop(self): + """Controller main loop in separated thread""" + + logger.trace("Controller: main_loop") + while True: + self.model.do_current_step_actions() + self.model.wait_for_current_step_is_ended() + self.model.set_new_step_in_sequence() + + def apply_view_user_settings(self, user_settings: UserSettingsData) -> None: + logger.trace("EGModel: apply_view_user_settings") + self.model.apply_new_user_settings(user_settings) + + def switch_suspended_state(self): + self.model.switch_suspended_state() + + def set_step(self, new_step_type: StepType): + logger.trace("Controller: set_step") + self.model.set_step(new_step_type) diff --git a/src/logger.py b/src/logger.py new file mode 100644 index 0000000..32dc5e6 --- /dev/null +++ b/src/logger.py @@ -0,0 +1,29 @@ +from pathlib import Path as __Path + +import loguru as __loguru + +__LOG_LEVEL_DEBUG = "DEBUG" +__LOG_LEVEL_TRACE = "TRACE" +__LOG_LEVEL_WARNING = "WARNING" +__LOG_FORMAT = "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {file}:{line}: {message}" + +__LOG_FILE = "./logs/log.log" + +log_file_path = __Path(__LOG_FILE) +if log_file_path.exists(): + log_file_path.unlink(missing_ok=True) + +logger = __loguru.logger + +logger.add( + sink=__LOG_FILE, + level=__LOG_LEVEL_WARNING, + format=__LOG_FORMAT, + colorize=False, + backtrace=True, + diagnose=True, + encoding="utf8", +) + + +logger.debug("Logging started!") diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..3917bad --- /dev/null +++ b/src/main.py @@ -0,0 +1,28 @@ +from controller import Controller +from logger import logger +from model import Model +from view import View + + +def main(): + """Main function""" + + logger.trace(f"Function started") + + controller = Controller() + model = Model() + view = View() + + controller.set_model(model=model) + model.set_view(view=view) + view.set_controller(controller=controller) + + controller.start() + logger.info("Program EyesGuard started!") + + view.mainloop() + + +# application entry point +if __name__ == "__main__": + main() diff --git a/src/model.py b/src/model.py new file mode 100644 index 0000000..8e6cd59 --- /dev/null +++ b/src/model.py @@ -0,0 +1,317 @@ +# For preventing circular import +# https://stackoverflow.com/questions/744373/what-happens-when-using-mutual-or-circular-cyclic-imports/67673741#67673741 +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from view import View + +import datetime +import time + +from logger import logger +from settings import OnOffValue, Settings, UserSettingsData +from states import CurrentState, StepData, StepType + +SETTINGS_FILE = "./settings/settings.json" + + +class Model: + TIME_TICK_S = 1 + + def __init__(self, settings_file: str = SETTINGS_FILE): + self.__view: View + self.__settings = Settings(settings_file) + self.__current_state = CurrentState() + + logger.info(f"User settings: = {self.__settings.user_settings}") + + self.steps_data_list: list[StepData] = [] + logger.info(self.__current_state) + + self.__remaining_time_to_display = datetime.timedelta(seconds=0) + self.__time_for_work_full = datetime.timedelta(seconds=0) + + logger.trace("Model: object was created") + + def __init_steps(self): + for step_type in StepType: + self.steps_data_list.insert(step_type, StepData()) + self.steps_data_list[step_type].step_type = step_type + + # setting steps durations + self.step_off_duration_td = self.__settings.system_settings.step_suspended_mode_duration + self.step_suspended_duration_td = self.__settings.system_settings.step_suspended_mode_duration + self.step_work_duration_td = datetime.timedelta(minutes=self.__settings.user_settings.work_duration) + self.step_break_duration_td = datetime.timedelta(minutes=self.__settings.user_settings.break_duration) + self.step_notification_1_time_td = self.__settings.system_settings.step_notification_1_duration + self.step_notification_2_time_td = self.__settings.system_settings.step_notification_2_duration + logger.info(f"step_work_duration_td {self.step_work_duration_td}") + + self.steps_data_list[StepType.off_mode].step_duration_td = self.step_suspended_duration_td + self.steps_data_list[StepType.suspended_mode].step_duration_td = self.step_suspended_duration_td + if self.step_work_duration_td > self.step_notification_1_time_td + self.step_notification_2_time_td: + logger.info( + f"cond 1 {self.step_work_duration_td} {self.step_notification_1_time_td + self.step_notification_2_time_td}" + ) + self.steps_data_list[StepType.work_mode].step_duration_td = ( + self.step_work_duration_td + - self.step_notification_1_time_td + - self.step_notification_2_time_td + ) + else: + self.steps_data_list[StepType.work_mode].step_duration_td = datetime.timedelta(seconds=0) + + self.steps_data_list[StepType.work_notified_1].step_duration_td = self.step_notification_1_time_td + self.steps_data_list[StepType.work_notified_2].step_duration_td = self.step_notification_2_time_td + self.steps_data_list[StepType.break_mode].step_duration_td = self.step_break_duration_td + + logger.info(self.steps_data_list) + for step in self.steps_data_list: + logger.info(f"{step.step_type=}") + logger.info(step.step_duration_td) + + # set initial step + logger.debug(f"Off mode: {self.__settings.user_settings.protection_status}") + logger.debug(f"Off value: {OnOffValue.off}") + if self.__settings.user_settings.protection_status == OnOffValue.off.value: + logger.debug("Model: init with off_mode") + self.set_step(new_step_type=StepType.off_mode) + else: + logger.debug("Model: init with work_mode") + self.set_step(new_step_type=StepType.work_mode) + + self.__view.update_all_wnd_values(self.model) + + def __update_view(self): + """View initialization with model data""" + logger.trace("Model: __init_view function") + self.__init_steps() + + if self.__view is not None: + self.__view.update_all_wnd_values(self.model) + + def __update_tray_icon_values(self): + """Updating tray icon values""" + logger.trace("Controller: __update_tray_icon_values") + + self.__calculate_remaining_time_for_work() + self.__view.update_tray_icon_values(self.model) + + def __update_wnd_status(self): + """Updating status window values""" + logger.trace("Controller: __update_wnd_status_values") + + self.__remaining_time_to_display = self.__calculate_remaining_time_for_work() + self.__time_for_work_full = self.__calculate_time_for_work() + self.__view.update_wnd_status(self.model) + + def __update_wnd_settings(self): + self.__view.update_wnd_settings(self.model) + + def __update_wnd_break(self): + """Updating break window values""" + logger.trace("Controller: __update_wnd_break_values") + self.__view.update_wnd_break(self.model) + + def __calculate_remaining_time_for_work(self) -> datetime.timedelta: + remaining_time_actual: datetime.timedelta = self.model.__current_state.current_step_remaining_time + logger.debug(remaining_time_actual) + match self.model.__current_state.current_step_type: + case StepType.off_mode: + remaining_time_actual += ( + self.model.steps_data_list[StepType.work_mode].step_duration_td + + self.model.steps_data_list[StepType.work_notified_1].step_duration_td + + self.model.steps_data_list[StepType.work_notified_2].step_duration_td + ) + + case StepType.work_mode: + remaining_time_actual += ( + self.model.steps_data_list[StepType.work_notified_1].step_duration_td + + self.model.steps_data_list[StepType.work_notified_2].step_duration_td + ) + case StepType.work_notified_1: + remaining_time_actual += self.model.steps_data_list[StepType.work_notified_2].step_duration_td + return remaining_time_actual + + def __calculate_time_for_work(self) -> datetime.timedelta: + time_for_work = ( + self.model.steps_data_list[StepType.work_mode].step_duration_td + + self.model.steps_data_list[StepType.work_notified_1].step_duration_td + + self.model.steps_data_list[StepType.work_notified_2].step_duration_td + ) + match self.model.__current_state.current_step_type: + case StepType.off_mode: + time_for_work = ( + self.model.steps_data_list[StepType.off_mode].step_duration_td + + self.model.steps_data_list[StepType.work_mode].step_duration_td + + self.model.steps_data_list[StepType.work_notified_1].step_duration_td + + self.model.steps_data_list[StepType.work_notified_2].step_duration_td + ) + return time_for_work + + def __set_current_step(self, step_type: StepType) -> None: + """settind step data in current state by step type""" + logger.trace("Model: __set_current_step") + logger.debug(f"Model: new step {step_type}") + + self.model.__current_state.reset_elapsed_time() + self.model.__current_state.set_current_step_data( + step_type=step_type, step_duration=self.model.steps_data_list[step_type].step_duration_td + ) + logger.debug(f"Step_type: {self.model.__current_state.current_step_type}") + logger.debug(f"Step_duration: {self.model.__current_state.current_step_duration}") + logger.debug(f"Steps data list: {self.model.steps_data_list[step_type]}") + + @property + def model(self) -> Model: + logger.trace("Model: getting model data") + return self + + @property + def remaining_working_time_to_display(self) -> datetime.timedelta: + logger.trace("Model: remaining_working_time_to_display property") + return self.__remaining_time_to_display + + @property + def time_for_work_full(self) -> datetime.timedelta: + logger.trace("Model: time_for_work_full property") + return self.__time_for_work_full + + @property + def current_state(self) -> CurrentState: + logger.trace("Model: current_state") + return self.__current_state + + @property + def settings(self) -> Settings: + logger.trace("Model: model_settings getter") + return self.__settings + + @settings.setter + def settings(self, settings: Settings) -> None: + logger.trace("Model: model_settings setter") + + @property + def user_settings(self) -> UserSettingsData: + logger.trace("Model: model_user_settings getter") + return self.__settings.user_settings + + @user_settings.setter + def user_settings(self, user_settings: UserSettingsData) -> None: + logger.trace("Model: model_user_settings setter") + self.__settings.apply_settings_from_ui(user_settings) + + def set_view(self, view: View) -> None: + """Assigning controller to view""" + logger.trace("Model: set_view started") + self.__view = view + self.__update_view() + + def do_current_step_actions(self): + logger.trace("Model: __do_current_step_actions") + logger.debug(f"Model: New current step {(self.__current_state.current_step_type)}") + logger.debug(f"Model: New step type {type(self.__current_state.current_step_type)}") + logger.debug(f"Model: New step duration {self.__current_state.current_step_duration}") + logger.debug(f"Model: New step remaining_time {self.__current_state.current_step_remaining_time}") + + match self.model.__current_state.current_step_type: + case StepType.off_mode: + logger.trace("Model: off_mode actions") + self.__view.show_notification("Eyes Guard protection is off!", "Attention!") + + case StepType.suspended_mode: + logger.trace("Model: off_mode actions") + self.__view.show_notification("Eyes Guard protection suspended!", "Attention!") + + case StepType.break_mode: + logger.trace("Model: break_mode actions") + self.__update_wnd_break() + + case StepType.work_notified_1: + logger.trace("Model: work_notified_1 actions") + if self.__settings.user_settings.notifications == "on": + pass + self.__view.show_notification("Break will start in 1 minute!", "Attention!") + + case StepType.work_notified_2: + logger.trace("Model: work_notified_2 actions") + if self.__settings.user_settings.notifications == "on": + self.__view.show_notification("Break will start in 5 seconds!", "Attention!") + + case StepType.work_mode: + logger.trace("Model: work_mode actions") + self.__view.update_wnd_break(model=self.model) + + def wait_for_current_step_is_ended(self): + logger.trace("Model: __wait_for_current_step_is_ended") + while True: + logger.info(f"Current step type: {self.model.__current_state.current_step_type}") + logger.info(f"Step duration: {self.model.__current_state.current_step_duration}") + logger.info(f"Step elapsed time: {self.model.__current_state.current_step_elapsed_time}") + + if self.__current_state.current_step_type != StepType.off_mode: + if ( + self.model.__current_state.current_step_elapsed_time + < self.model.__current_state.current_step_duration + ): + self.model.__current_state.increase_elapsed_time() + else: + break + + # actions during step is in progress + match self.model.__current_state.current_step_type: + case StepType.break_mode: + self.__update_wnd_break() + + case _: + self.__update_wnd_status() + self.__update_tray_icon_values() + + time.sleep(self.TIME_TICK_S) + + def set_new_step_in_sequence(self): + logger.trace("Controller: __set_new_step_in_sequence") + + # steps transitions + match self.__current_state.current_step_type: + case StepType.off_mode: + new_step_type = self.current_state.current_step_type + pass + case StepType.suspended_mode: + new_step_type = StepType.work_mode + case StepType.work_mode: + new_step_type = StepType.work_notified_1 + case StepType.work_notified_1: + new_step_type = StepType.work_notified_2 + case StepType.work_notified_2: + new_step_type = StepType.break_mode + case StepType.break_mode: + new_step_type = StepType.work_mode + + logger.debug(f"Model: new step = {new_step_type}") + self.__set_current_step(new_step_type) + + def apply_new_user_settings(self, user_settings: UserSettingsData) -> None: + logger.trace("Model: apply_new_settings") + self.user_settings = user_settings + self.__init_steps() + + def switch_suspended_state(self): + logger.trace("Model: change_suspended_state") + if self.current_state.current_step_type != StepType.suspended_mode: + self.set_step(StepType.suspended_mode) + logger.debug("Model: show notification") + self.__view.show_notification("Eyes Guard protection suspended!", "Attention!") + + else: + self.set_step(StepType.work_mode) + + def set_step(self, new_step_type: StepType): + logger.trace("Model: set_step") + self.__set_current_step(new_step_type) + self.do_current_step_actions() + self.__update_wnd_break() + self.__update_wnd_status() + self.__update_wnd_settings() diff --git a/src/resourses.py b/src/resourses.py new file mode 100644 index 0000000..e7d06bf --- /dev/null +++ b/src/resourses.py @@ -0,0 +1,17 @@ +"""Dataclasses for resources""" + +from PIL import Image + + +class ResImages: + img_protection_active = Image.open("res/img/eyes_with_protection.png") + img_protection_suspended = Image.open("res/img/eyes_without_protection.png") + img_protection_off = Image.open("res/img/eyes_protection_off.png") + + img_check_mark = Image.open("res/img/check_mark.png") + + img_break_wnd_bg = Image.open("res/img/break_wnd_bg1.png") + + img_clock = Image.open("res/img/clock.png") + img_gear = Image.open("res/img/gear.png") + img_info = Image.open("res/img/info.png") diff --git a/src/settings.py b/src/settings.py new file mode 100644 index 0000000..e1ce092 --- /dev/null +++ b/src/settings.py @@ -0,0 +1,181 @@ +"""Module for settings management in application""" + +import copy +import datetime +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, Field, ValidationError + +from logger import logger + + +class OnOffValue(Enum): + on = "on" + off = "off" + + +@dataclass +class SystemSettingsData: + """class for storing system settings""" + + step_suspended_mode_duration = datetime.timedelta(minutes=60) + step_notification_1_duration = datetime.timedelta(seconds=55) + step_notification_2_duration = datetime.timedelta(seconds=5) + + +@dataclass +class UserSettingsData: + """class for storing user settings""" + + work_duration: int = 45 + break_duration: int = 15 + sounds = OnOffValue.on.value + notifications = OnOffValue.on.value + protection_status = OnOffValue.on.value + + def _settings_to_dict(self) -> dict: + # convertation object to dict + logger.trace("UserSettingsData: __settings_to_dict") + settings_dict = {} + for attr in dir(self): + if not attr.startswith("_"): + settings_dict[attr] = getattr(self, attr) + logger.debug(f"UserSettingsData: {settings_dict}") + return settings_dict + + def __repr__(self) -> dict: + # convertation object to string + return self._settings_to_dict() + + def __str__(self) -> str: + # convertation object to string + return str(self._settings_to_dict()) + + +class SettingsDataValidator(BaseModel): + """Validation model for settings""" + + work_duration: int = Field(default=45, gt=0, lt=101) + break_duration: int = Field(default=15, gt=0, lt=101) + sounds: Literal["on", "off"] = "on" + notifications: Literal["on", "off"] = "on" + protection_status: Literal["on", "off"] = "on" + + +class Settings: + """class for managing user settings""" + + def __init__(self, file_name: str | None = None): + self.__user_settings = UserSettingsData() + self.__system_settings = SystemSettingsData() + if file_name is not None: + self.__settings_file = Path(file_name) + self.apply_settings_from_file() + + def _settings_to_dict(self) -> dict: + """Convertation settings object to dict""" + settings_dict = {} + for attr in dir(self.__user_settings): + if not attr.startswith("_"): + settings_dict[attr] = getattr(self.__user_settings, attr) + return settings_dict + + def __repr__(self) -> dict: + """Convertation settings object to view in terminal""" + return self._settings_to_dict() + + def __str__(self) -> str: + """Convertation object to string""" + return str(self._settings_to_dict()) + + def __read_settings_from_file(self) -> str | None: + """Reading settings from file on disk""" + settings_str = None + try: + settings_str = self.__settings_file.read_text("utf-8") + except FileNotFoundError as error: + logger.error(error) + logger.error(type(error)) + return None + except ValueError as error: + logger.error(error) + logger.error(type(error)) + return None + return settings_str + + def __validate_settings_str(self, settings_str: str) -> SettingsDataValidator | None: + """Validation settings""" + logger.debug(settings_str) + if settings_str is not None: + try: + settings_validated = SettingsDataValidator.model_validate_json(settings_str) + logger.debug(settings_validated) + return settings_validated + except ValidationError as error: + logger.error(error) + logger.error(type(error)) + return None + return None + + def __apply_settings(self, validated_settings: SettingsDataValidator) -> None: + """Apply given settings to the app""" + if validated_settings is not None: + for attr in dir(self.__user_settings): + if not attr.startswith("_"): + logger.debug(f"Settings: get attr: {attr}") + setattr(self.__user_settings, attr, getattr(validated_settings, attr)) + logger.debug(getattr(self.__user_settings, attr)) + + def apply_settings_from_file(self): + """Read, validate and apply settings from file""" + logger.trace("Settings: apply_settings_from_file") + settings_from_file_str = self.__read_settings_from_file() + settings_validated = self.__validate_settings_str(settings_from_file_str) + logger.debug(f"Settings from file {settings_validated}") + self.__apply_settings(settings_validated) + + def apply_settings_from_ui(self, new_settings_data: UserSettingsData): + """Validate settings and write them to file""" + logger.debug(f"New settings from ui to apply: {new_settings_data}") + logger.debug(type(new_settings_data)) + try: + settings_validated = SettingsDataValidator.model_validate(new_settings_data.__repr__()) + except ValidationError as error: + logger.error(error) + logger.error(type(error)) + return + self.__apply_settings(settings_validated) + self.save_settings_to_file() + + def save_settings_to_file(self): + """Save settings to file""" + logger.trace("Settings: save_settings_to_file") + settings_dict = self._settings_to_dict() + logger.debug(settings_dict) + logger.debug(type(settings_dict)) + try: + settings_validated = SettingsDataValidator.model_validate(settings_dict) + except ValidationError as error: + logger.error(error) + logger.error(type(error)) + return + settings_json = settings_validated.model_dump_json(indent=4) + logger.debug(f"Settings to file {settings_json}") + self.__settings_file.write_text(settings_json, encoding="utf-8") + + def get_settings_copy(self) -> UserSettingsData: + """Return copy of settings object""" + return copy.copy(self.__user_settings) + + @property + def user_settings(self) -> UserSettingsData: + """Return of user settings object""" + return self.__user_settings + + @property + def system_settings(self) -> SystemSettingsData: + """Return of system settings object""" + return self.__system_settings diff --git a/src/states.py b/src/states.py new file mode 100644 index 0000000..c431d04 --- /dev/null +++ b/src/states.py @@ -0,0 +1,94 @@ +import datetime +from dataclasses import dataclass +from enum import IntEnum + +from logger import logger + + +class StepType(IntEnum): + """Types of programm steps""" + + off_mode = 0 + suspended_mode = 1 + work_mode = 2 + work_notified_1 = 3 + work_notified_2 = 4 + break_mode = 5 + # break_notified = 4 + + +@dataclass +class StepData: + """Data for step control""" + + step_type: StepType = StepType.off_mode + step_duration_td: datetime.timedelta = datetime.timedelta(seconds=0) + + +class CurrentState: + """Data about current step""" + + def __init__(self): + self.__step_type: StepType = StepType.work_mode + + self.__step_duration_dt: datetime.timedelta = datetime.timedelta(seconds=0) + self.__elapsed_time_dt: datetime.timedelta = datetime.timedelta(seconds=0) + self.__suspended_mode_active: bool = False + + logger.debug(f"Init current state: {self}") + + def __self_to_dict(self) -> dict: + # convertation object to dict + self_dict = { + "__step_type:": self.__step_type, + "__step_duration_dt": self.__step_duration_dt, + "__elapsed_time_dt": self.__elapsed_time_dt, + } + return self_dict + + def __repr__(self) -> dict: + # convertation object to dict + return self.__self_to_dict() + + def __str__(self) -> str: + # convertation object to string + return str(self.__self_to_dict()) + + @property + def current_step_type(self) -> StepType: + return self.__step_type + + @property + def current_step_duration(self): + return self.__step_duration_dt + + @property + def current_step_elapsed_time(self): + return self.__elapsed_time_dt + + @property + def current_step_remaining_time(self) -> datetime.timedelta: + return self.__step_duration_dt - self.__elapsed_time_dt + + @property + def suspended_mode_active(self) -> bool: + return self.__suspended_mode_active + + def set_current_step_data( + self, step_type: StepType, step_duration: datetime.timedelta = datetime.timedelta(seconds=0) + ): + """Setting current step type and its duration""" + logger.trace("CurrentState: set_current_step_data") + self.__step_type = step_type + self.__step_duration_dt = step_duration + logger.debug(f"__step_type = {self.__step_type}") + logger.debug(f"__step_duration_dt = {self.__step_duration_dt}") + logger.debug(f"__elapsed_time_dt = {self.__elapsed_time_dt}") + + def increase_elapsed_time(self, time_delta: datetime.timedelta = datetime.timedelta(seconds=1)): + logger.trace("CurrentState: increase_elapsed_time") + self.__elapsed_time_dt += time_delta + logger.debug(f"__elapsed_time_dt: {self.__elapsed_time_dt}") + + def reset_elapsed_time(self): + self.__elapsed_time_dt = datetime.timedelta(seconds=0) diff --git a/src/view.py b/src/view.py new file mode 100644 index 0000000..52b5da7 --- /dev/null +++ b/src/view.py @@ -0,0 +1,140 @@ +"""Module with main widnow of application""" + +import os + +import customtkinter +import pystray + +from controller import Controller +from logger import logger +from model import Model +from resourses import ResImages +from settings import Settings, UserSettingsData +from states import CurrentState, StepType +from windows.wnd_break import WndBreak +from windows.wnd_settings import WndSettings +from windows.wnd_status import WndStatus + + +class View(customtkinter.CTk): + """Main window of application""" + + def __init__(self): + super().__init__() + customtkinter.set_appearance_mode("light") + customtkinter.set_default_color_theme("blue") + + self.image_protection_active = ResImages.img_protection_active + self.image_protection_suspended = ResImages.img_protection_suspended + self.image_protection_off = ResImages.img_protection_off + + self.__settings = Settings() + self.__current_state = CurrentState() + self.__wnd_settings = WndSettings(self, self.__settings) + self.__wnd_break = WndBreak(self, self.__current_state) + self.__wnd_status = WndStatus(self) + self.title("EyesGuard v2.0.0") + + # tray icon + menu = ( + pystray.MenuItem("Status", self.__show_status_wnd, default=True), + pystray.MenuItem("Settings", self.__show_settings_wnd), + pystray.MenuItem("Exit", self.exit_app), + ) + self.__tray_icon = pystray.Icon("name", self.image_protection_active, "Eyes Guard", menu) + self.__tray_icon.run_detached() + + # hide main app wnd + self.withdraw() + logger.trace("View: object was created") + + def __show_status_wnd(self): + """Show status wnd""" + logger.trace("View: show status wnd") + self.__wnd_status.show() + + def __show_settings_wnd(self): + """Show settings wnd""" + logger.trace("View: show settings wnd") + self.__wnd_settings.show() + + def show_notification(self, title: str, text: str): + logger.trace("View: show_notification") + self.__tray_icon.notify(title, text) + + def set_controller(self, controller: Controller): + """Assigning controller to view""" + self.controller = controller + logger.trace("View: controller was set") + + def exit_app(self): + """Exit from app""" + self.__tray_icon.visible = False + self.__tray_icon.stop() + self.quit() + os._exit(0) + + def update_all_wnd_values(self, model: Model): + """Init all data at windows""" + logger.trace("View: init_all_views function started") + self.__wnd_status.update(model) + self.__wnd_settings.update(model) + + def apply_view_user_settings(self): + logger.trace("View: applying new settings") + self.controller.apply_view_user_settings(self.__get_user_settings_from_wnd_settings()) + + def __get_user_settings_from_wnd_settings(self) -> UserSettingsData: + """Get data from all widgets with settings""" + ui_settings_data = UserSettingsData() + try: + ui_settings_data.work_duration = int(self.__wnd_settings.work_duration_value.get()) + ui_settings_data.break_duration = int(self.__wnd_settings.break_duration_value.get()) + ui_settings_data.protection_status = str(self.__wnd_settings.chbox_protection_status_value.get()) + ui_settings_data.sounds = str(self.__wnd_settings.chbox_sounds_value.get()) + ui_settings_data.notifications = str(self.__wnd_settings.chbox_notifications_value.get()) + ui_settings_data.protection_status = str(self.__wnd_settings.chbox_protection_status_value.get()) + except TypeError as error: + logger.error(f"Error occuired while reading settings from ui: {error}") + + logger.info(f"Settings read from ui: {str(ui_settings_data)}") + + return ui_settings_data + + def show_wnd_break(self): + self.__wnd_break.show() + + def hide_wnd_break(self): + self.__wnd_break.hide() + + def update_wnd_status(self, model: Model) -> None: + self.__wnd_status.update(model) + + def update_wnd_settings(self, model: Model) -> None: + self.__wnd_settings.update(model) + + def update_wnd_break(self, model: Model) -> None: + self.__wnd_break.update(model) + + def update_tray_icon_values(self, model: Model) -> None: + logger.trace("View: update_tray_icon_values") + self.__tray_icon.title = f"Time until break: {model.remaining_working_time_to_display}" + if model.current_state.current_step_type == StepType.off_mode: + logger.debug("View: off cond") + self.__tray_icon.title = "Protection off" + self.__tray_icon.icon = self.image_protection_off + elif model.current_state.current_step_type == StepType.suspended_mode: + self.__tray_icon.icon = self.image_protection_suspended + self.__tray_icon.title = ( + f"Time until normal mode: {model.current_state.current_step_remaining_time}" + ) + else: + self.__tray_icon.icon = self.image_protection_active + + def switch_suspended_state(self): + logger.trace("View: switch_suspended_state") + self.controller.switch_suspended_state() + + def set_step(self, new_step_type: StepType): + logger.trace("View: set_step") + self.controller.set_step(new_step_type) diff --git a/src/windows/__init__.py b/src/windows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/windows/wnd_break.py b/src/windows/wnd_break.py new file mode 100644 index 0000000..456033f --- /dev/null +++ b/src/windows/wnd_break.py @@ -0,0 +1,115 @@ +"""Module with break window of application""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from view import View + +import datetime +import time +from tkinter import StringVar + +import customtkinter +from PIL import Image + +from logger import logger +from model import Model +from resourses import ResImages +from states import CurrentState, StepType + + +class WndBreak(customtkinter.CTkToplevel): + """Break window""" + + def __init__(self, view: View, current_state: CurrentState, *args, **kwargs): + super().__init__(*args, fg_color="#000000", **kwargs) + self.current_state = current_state + self.view = view + + ws = self.winfo_screenwidth() # width of the screen + hs = self.winfo_screenheight() # height of the screen + self.attributes("-alpha", 0) + print(ws, hs) + self.geometry("%dx%d" % (ws, hs)) + self.title("EyesGuard v2.0.0") + + self.attributes("-topmost", True) + self.attributes("-fullscreen", True) + self.resizable(False, False) + + self.grid_rowconfigure(0, weight=1) + self.bg_image = customtkinter.CTkImage(ResImages.img_break_wnd_bg, size=(ws, hs)) + self.bg_image_label = customtkinter.CTkLabel(self, image=self.bg_image, text="") + self.bg_image_label.grid(row=0, column=0, rowspan=2) + + self.remaining_break_time = StringVar() + + self.remaining_break_time.set(f"Remaining break time: 0 seconds") + self.lbl_remain_time = customtkinter.CTkLabel( + self, + text="Remaining break time: ", + text_color="GreenYellow", + textvariable=self.remaining_break_time, + font=customtkinter.CTkFont(size=18, weight="bold"), + ) + self.lbl_remain_time.grid(row=1, column=0, padx=20, pady=5) + + self.grid_columnconfigure(0, weight=1) + self.pbar_break_progress = customtkinter.CTkProgressBar( + self, orientation="horizontal", fg_color="GreenYellow", determinate_speed=0.05 + ) + self.pbar_break_progress.grid(row=2, column=0, padx=20, pady=5, sticky="ew") + + self.protocol("WM_DELETE_WINDOW", self.on_close_action) + self.withdraw() + + def hide(self): + """Hide window""" + logger.trace("Wnd break: hide") + + for i in range(100): + self.attributes("-alpha", 1 - i / 100) + time.sleep(0.006) + self.withdraw() + + def on_close_action(self): + logger.trace("Wnd break: on_close_action") + + self.view.set_step(new_step_type=StepType.work_mode) + + def show(self): + """Show window""" + logger.trace("Break wnd: show") + + self.deiconify() + + for i in range(100): + self.attributes("-alpha", i / 100) + time.sleep(0.006) + + def update(self, model: Model): + logger.trace("WndBreak: update") + + if model.current_state.current_step_type == StepType.break_mode: + if model.current_state.current_step_elapsed_time > datetime.timedelta(seconds=0): + pbar_value = ( + model.current_state.current_step_elapsed_time / model.current_state.current_step_duration + ) + else: + pbar_value = 0 + self.pbar_break_progress.set(pbar_value) + self.remaining_break_time.set( + f"Remaining break time: {model.steps_data_list[StepType.break_mode].step_duration_td - model.current_state.current_step_elapsed_time}" + ) + if self.state() != "normal": + self.show() + else: + if self.state() == "normal": + self.hide() + + self.pbar_break_progress.set(0) + self.remaining_break_time.set( + f"Remaining break time: {model.steps_data_list[StepType.break_mode].step_duration_td}" + ) diff --git a/src/windows/wnd_settings.py b/src/windows/wnd_settings.py new file mode 100644 index 0000000..6671da7 --- /dev/null +++ b/src/windows/wnd_settings.py @@ -0,0 +1,371 @@ +"""Module with settings window of application""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from view import View + +import re +import time +from tkinter import StringVar + +import customtkinter +from PIL import Image, ImageTk + +from logger import logger +from model import Model +from resourses import ResImages +from settings import Settings +from states import StepType + + +class WndSettings(customtkinter.CTkToplevel): + """Settings window""" + + def __init__(self, view: View, settings: Settings, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.view = view + self.settings = settings + wnd_width = 500 + wnd_height = 350 + border_x = 50 + border_y = 150 + self.attributes("-alpha", 0) + screen_width = self.winfo_screenwidth() + screen_height = self.winfo_screenheight() + self.geometry( + f"{wnd_width}x{wnd_height}+" + f"{screen_width - wnd_width - border_x}+{screen_height - wnd_height - border_y}" + ) + self.title("EyesGuard v2.0.0 - Settings") + self.attributes("-topmost", True) + self.resizable(False, False) + + self.wnd_icon = ImageTk.PhotoImage(file="res/img/eyes_with_protection.png") + self.after(250, lambda: self.iconphoto(False, self.wnd_icon)) + + self.configure(fg_color="LightSteelBlue") + + # images + self.img_eyes_with_protection = customtkinter.CTkImage( + self.view.image_protection_active, size=(50, 50) + ) + self.img_eyes_protection_suspended = customtkinter.CTkImage( + self.view.image_protection_suspended, size=(50, 50) + ) + self.img_eyes_protection_off = customtkinter.CTkImage(self.view.image_protection_off, size=(50, 50)) + self.img_clock = customtkinter.CTkImage(ResImages.img_clock, size=(25, 25)) + self.img_gear = customtkinter.CTkImage(ResImages.img_gear, size=(25, 25)) + self.img_info = customtkinter.CTkImage(ResImages.img_info, size=(25, 25)) + + # set grid layout 2x2 + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(1, weight=1) + + # window elements + # create navigation frame + self.navigation_frame = customtkinter.CTkFrame(self, corner_radius=0, fg_color="SteelBlue") + self.navigation_frame.grid(row=0, column=0, sticky="nsew") + + self.navigation_frame_lbl_title = customtkinter.CTkLabel( + self.navigation_frame, + text=" EyesGuard", + text_color="White", + image=self.img_eyes_with_protection, + compound="left", + font=customtkinter.CTkFont(size=18, weight="bold"), + ) + self.navigation_frame_lbl_title.grid(row=0, column=0, padx=20, pady=5) + + self.navigation_frame_lbl_description = customtkinter.CTkLabel( + self.navigation_frame, + text="Cares about your vision", + text_color="GreenYellow", + compound="center", + font=customtkinter.CTkFont(size=13, weight="bold"), + ) + self.navigation_frame_lbl_description.grid(row=1, column=0, padx=0, pady=1) + + self.btn_time_settings = customtkinter.CTkButton( + self.navigation_frame, + corner_radius=0, + height=40, + border_spacing=5, + border_width=1, + text="Time settings", + font=("", 14, "bold"), + fg_color="LightSteelBlue", + text_color=("gray10", "gray90"), + hover_color=("LightSkyBlue", "gray30"), + border_color="LightSteelBlue", + # mage=self.image, + anchor="w", + command=self.event_btn_time_settings_click, + image=self.img_clock, + ) + self.btn_time_settings.grid(row=2, column=0, sticky="ew") + + self.btn_general_settings = customtkinter.CTkButton( + self.navigation_frame, + corner_radius=0, + height=40, + border_spacing=5, + border_width=1, + text="General", + font=("", 14, "bold"), + fg_color="transparent", + text_color=("gray10", "gray90"), + hover_color=("LightSkyBlue", "gray30"), + border_color="LightSteelBlue", + anchor="w", + command=self.event_btn_general_settings_click, + image=self.img_gear, + ) + self.btn_general_settings.grid(row=3, column=0, sticky="ew") + + self.btn_about = customtkinter.CTkButton( + self.navigation_frame, + corner_radius=0, + height=40, + border_spacing=5, + border_width=1, + text="About", + font=("", 14, "bold"), + fg_color="transparent", + text_color=("gray10", "gray90"), + hover_color=("LightSkyBlue", "gray30"), + border_color="LightSteelBlue", + anchor="w", + command=self.event_btn_about_click, + image=self.img_info, + ) + self.btn_about.grid(row=4, column=0, sticky="ew") + + # --- create time settings frame --- + self.frame_time_settings = customtkinter.CTkFrame(self, corner_radius=0, fg_color="transparent") + self.frame_time_settings.grid_columnconfigure(1, weight=1) + + # work duratrion setting + self.lbl_work_duration = customtkinter.CTkLabel( + self.frame_time_settings, text="Work duration (minutes)", font=("", 13) + ) + self.lbl_work_duration.grid(row=0, column=0, padx=20, pady=10) + + self.work_duration_value = StringVar() + self.work_duration_value.set(self.settings.get_settings_copy().work_duration) + verify_cmd_work_duration = (self.register(self.is_valid_duration_entry), "%P") + self.entry_work_duration = customtkinter.CTkEntry( + self.frame_time_settings, + textvariable=self.work_duration_value, + justify="center", + validate="key", + validatecommand=verify_cmd_work_duration, + ) + self.entry_work_duration.grid(row=0, column=1, padx=20, pady=20, sticky="e") + + # break duratrion setting + self.lbl_break_duration = customtkinter.CTkLabel( + self.frame_time_settings, text="Break duration (minutes)", font=("", 13) + ) + self.lbl_break_duration.grid(row=1, column=0, padx=20, pady=10) + + self.break_duration_value = StringVar() + self.break_duration_value.set(self.settings.get_settings_copy().break_duration) + verify_cmd_break_duration = (self.register(self.is_valid_duration_entry), "%P") + self.entry_break_duration = customtkinter.CTkEntry( + self.frame_time_settings, + textvariable=self.break_duration_value, + justify="center", + validate="key", + validatecommand=verify_cmd_break_duration, + ) + self.entry_break_duration.grid(row=1, column=1, padx=(20, 20), pady=(20, 20), sticky="e") + + # --- create general settings frame --- + self.frame_general_settings = customtkinter.CTkFrame(self, corner_radius=0, fg_color="transparent") + self.frame_time_settings.grid_columnconfigure(1, weight=1) + + # protextion status setting + self.chbox_protection_status_value = customtkinter.StringVar( + value=self.settings.get_settings_copy().protection_status + ) + self.chbox_protection_status = customtkinter.CTkCheckBox( + self.frame_general_settings, + text="Protection status", + variable=self.chbox_protection_status_value, + onvalue="on", + offvalue="off", + font=("", 13), + ) + self.chbox_protection_status.grid(row=0, column=0, padx=20, pady=10, sticky="ew") + + # sounds setting + self.chbox_sounds_value = customtkinter.StringVar(value=self.settings.get_settings_copy().sounds) + self.chbox_sounds = customtkinter.CTkCheckBox( + self.frame_general_settings, + text="Sounds enabled", + variable=self.chbox_sounds_value, + onvalue="on", + offvalue="off", + font=("", 13), + ) + # Temporaly not used + # self.chbox_sounds.grid(row=1, column=0, padx=20, pady=10, sticky="ew") + + # notifications setting + self.chbox_notifications_value = customtkinter.StringVar( + value=self.settings.get_settings_copy().notifications + ) + self.chbox_notifications = customtkinter.CTkCheckBox( + self.frame_general_settings, + text="Notifications enabled", + variable=self.chbox_notifications_value, + onvalue="on", + offvalue="off", + font=("", 13), + ) + # Temporaly not used + # self.chbox_notifications.grid(row=2, column=0, padx=20, pady=10, sticky="ew") + + # --- create frame about--- + self.frame_about = customtkinter.CTkFrame(self, corner_radius=0, fg_color="transparent") + self.frame_about.grid_columnconfigure(1, weight=1) + + self.lbl_about_text = "\nEyesGuard v2.0.0\n\nAuthor: Dmitry Vorobjev\nSite: www.eyesguard.ru\nE-mail: eyesguard@yandex.ru\nLicense: © GPL v3\nYear: 2024" + self.lbl_about = customtkinter.CTkLabel(self.frame_about, text=self.lbl_about_text, justify="left") + self.lbl_about.grid(row=1, column=0, padx=20, pady=10, sticky="e") + + # --- create left footer frame --- + self.frame_footer_left = customtkinter.CTkFrame( + self, corner_radius=0, fg_color="SteelBlue", height=50 + ) + + self.frame_footer_left.grid(row=1, column=0, sticky="sew") + + # --- create right footer frame --- + self.frame_footer_right = customtkinter.CTkFrame( + self, corner_radius=0, fg_color="transparent", height=50 + ) + self.frame_footer_right.grid_columnconfigure(0, weight=1) + self.frame_footer_right.grid_columnconfigure(1, weight=1) + self.frame_footer_right.grid(row=1, column=1, sticky="new") + + self.btn_apply = customtkinter.CTkButton( + self.frame_footer_right, + text="Apply", + width=50, + fg_color="Green", + hover_color="DarkGreen", + command=self.apply_ui_settings, + ) + self.btn_apply.grid(row=0, column=0, padx=30, pady=10, sticky="ew") + + self.discrad = customtkinter.CTkButton( + self.frame_footer_right, text="Hide", width=50, command=self.hide + ) + self.discrad.grid(row=0, column=1, padx=30, pady=10, sticky="ew") + + # actions after elements creation + self.bind("", self.on_focus_in) + self.protocol("WM_DELETE_WINDOW", self.hide) + self.event_btn_time_settings_click() + self.withdraw() + + def on_focus_in(self, event): + """Actions on focus at window""" + pass + + def on_focus_out(self, event): + """Actions on focus out of window""" + pass + + def hide(self): + """Hide window""" + for i in range(100): + self.attributes("-alpha", 1 - i / 100) + time.sleep(0.006) + self.withdraw() + + def show(self): + """Show window""" + print("showing top level wnd") + self.deiconify() + + for i in range(100): + self.attributes("-alpha", i / 100) + time.sleep(0.006) + + def is_valid_duration_entry(self, value: str): + print(value) + result = re.match("^[1-9][0-9]{0,1}$|^$", value) is not None + print(result) + return result + + def update_protection_status_image(self, model: Model): + """Updating protection status at settings window""" + match model.current_state.current_step_type: + case StepType.off_mode: + self.navigation_frame_lbl_title.configure(image=self.img_eyes_protection_off) + self.navigation_frame_lbl_description.configure(text="Protection off!", text_color="Black") + case StepType.suspended_mode: + self.navigation_frame_lbl_title.configure(image=self.img_eyes_protection_suspended) + self.navigation_frame_lbl_description.configure( + text="Protection suspended!", text_color="Tomato" + ) + case _: + self.navigation_frame_lbl_title.configure(image=self.img_eyes_with_protection) + self.navigation_frame_lbl_description.configure( + text="Cares about your vision", text_color="GreenYellow" + ) + + def update(self, model: Model): + """Updating status window elements states""" + logger.trace("Settings wnd: update function started") + logger.debug(f"Model settings: {model}") + + self.work_duration_value.set(str(model.user_settings.work_duration)) + self.break_duration_value.set(str(model.user_settings.break_duration)) + self.chbox_sounds_value.set(value=model.user_settings.sounds) + self.chbox_notifications_value.set(value=model.user_settings.notifications) + self.chbox_protection_status_value.set(value=model.user_settings.protection_status) + + self.update_protection_status_image(model) + + def select_frame_by_name(self, name: str) -> None: + self.btn_time_settings.configure( + fg_color="LightSteelBlue" if name == "frame_time_settings" else "transparent" + ) + self.btn_general_settings.configure( + fg_color="LightSteelBlue" if name == "frame_general_settings" else "transparent" + ) + self.btn_about.configure(fg_color="LightSteelBlue" if name == "frame_about" else "transparent") + + # show selected frame + if name == "frame_time_settings": + self.frame_time_settings.grid(row=0, column=1, sticky="nsew") + else: + self.frame_time_settings.grid_forget() + if name == "frame_general_settings": + self.frame_general_settings.grid(row=0, column=1, sticky="nsew") + else: + self.frame_general_settings.grid_forget() + if name == "frame_about": + self.frame_about.grid(row=0, column=1, sticky="nsew") + else: + self.frame_about.grid_forget() + + def event_btn_time_settings_click(self) -> None: + self.select_frame_by_name("frame_time_settings") + + def event_btn_general_settings_click(self) -> None: + self.select_frame_by_name("frame_general_settings") + + def event_btn_about_click(self) -> None: + self.select_frame_by_name("frame_about") + + def apply_ui_settings(self): + """Coll method of view for applying new settings""" + self.view.apply_view_user_settings() diff --git a/src/windows/wnd_status.py b/src/windows/wnd_status.py new file mode 100644 index 0000000..51d20f6 --- /dev/null +++ b/src/windows/wnd_status.py @@ -0,0 +1,182 @@ +"""Module with status window of application""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from view import View + +import datetime +import time + +import customtkinter +from PIL import Image + +from logger import logger +from model import Model +from resourses import ResImages +from states import StepType + + +class WndStatus(customtkinter.CTkToplevel): + """Status window""" + + def __init__(self, view: View, *args, **kwargs): + super().__init__(*args, **kwargs) + self.view = view + + wnd_width = 250 + wnd_height = 210 + border_x = 50 + border_y = 50 + self.attributes("-alpha", 0) + screen_width = self.winfo_screenwidth() # width of the screen + screen_height = self.winfo_screenheight() # height of the screen + self.geometry( + f"{wnd_width}x{wnd_height}+" + f"{screen_width - wnd_width - border_x}+{screen_height - wnd_height - border_y}" + ) + self.title("EyesGuard v2.0.0 - Status") + self.attributes("-topmost", True) + self.resizable(False, False) + self.attributes("-toolwindow", True) + + self.configure(fg_color="LightSteelBlue") + self.img_protection_active = ResImages.img_protection_active + self.img_protection_suspended = ResImages.img_protection_suspended + self.img_check_mark = ResImages.img_check_mark + + self.grid_columnconfigure((0), weight=1) + + # label time until break + self.lbl_time_until_break = customtkinter.CTkLabel( + self, text="Time until break: - : - : -", font=("", 13, "bold") + ) + self.lbl_time_until_break.grid(row=0, column=0, padx=20, pady=(20, 0), sticky="ew") + + # progress bar time until break + self.pbar_time_until_break = customtkinter.CTkProgressBar( + self, orientation="horizontal", height=20, fg_color="#3B8ED0", progress_color="GreenYellow" + ) + self.pbar_time_until_break.grid(row=1, column=0, padx=20, pady=(0, 10), sticky="ew") + logger.debug(f'progress_color = {self.pbar_time_until_break.cget("progress_color")}') + # button change protection state + self.btn_change_suspended_state = customtkinter.CTkButton( + self, + text="Protection active", + text_color="GreenYellow", + command=self.__btn_change_protection_state_action, + height=30, + width=120, + corner_radius=50, + image=customtkinter.CTkImage(light_image=self.img_protection_active, size=(25, 25)), + border_spacing=0, + font=("", 13, "bold"), + ) + self.btn_change_suspended_state.grid(row=2, column=0, padx=20, pady=10, sticky="ew") + + # button take a break + self.btn_take_break = customtkinter.CTkButton( + self, + text="Take a break now", + text_color="GreenYellow", + command=self.__btn_take_break_action, + height=30, + width=150, + corner_radius=50, + image=customtkinter.CTkImage(light_image=self.img_check_mark, size=(30, 30)), + border_spacing=0, + font=("", 13, "bold"), + ) + self.btn_take_break.grid(row=3, column=0, padx=20, pady=10, sticky="ew") + + self.bind("", self.on_focus_out) + self.protocol("WM_DELETE_WINDOW", self.hide) + self.withdraw() + + def on_focus_out(self, event): + """Action in calse of loosing focus""" + self.hide() + + def hide(self): + """Hide window""" + for i in range(100): + self.attributes("-alpha", 1 - i / 100) + time.sleep(0.006) + self.withdraw() + + def show(self): + """Show window""" + print("Showing status wnd") + self.deiconify() + + for i in range(100): + self.attributes("-alpha", i / 100) + time.sleep(0.006) + + def __btn_change_protection_state_action(self): + """Action for pressing changeing protection state button""" + self.view.switch_suspended_state() + + def __btn_take_break_action(self): + """Action for pressing button for taking a break""" + logger.trace("Wnd_status: __btn_take_break_action") + self.view.set_step(StepType.break_mode) + + def update(self, model: Model): + """Updating status window elements states""" + logger.trace("Wnd status: update") + if model.time_for_work_full > datetime.timedelta(seconds=0): + pbar_value = 1 - model.remaining_working_time_to_display / model.time_for_work_full + else: + pbar_value = 0 + self.pbar_time_until_break.set(value=pbar_value) + + match model.current_state.current_step_type: + case StepType.off_mode: + if self.btn_change_suspended_state.cget("state") != "disabled": + self.btn_take_break.configure(state="disabled") + self.lbl_time_until_break.configure(text="Time until break: ∞ : ∞ : ∞") + self.btn_change_suspended_state.configure( + text="Protection off", + image=customtkinter.CTkImage( + light_image=self.view.image_protection_off, size=(30, 30) + ), + require_redraw=True, + state="disabled", + ) + self.pbar_time_until_break.set(0) + + case StepType.suspended_mode: + if self.btn_take_break.cget("state") != "disabled": + self.btn_take_break.configure(state="disabled") + + self.btn_change_suspended_state.configure( + text="Protection suspended", + text_color="Tomato", + image=customtkinter.CTkImage( + light_image=self.view.image_protection_suspended, size=(30, 30) + ), + require_redraw=True, + ) + self.lbl_time_until_break.configure( + text=f"Time until normal mode: {model.current_state.current_step_duration - model.current_state.current_step_elapsed_time}" + ) + case _: + self.lbl_time_until_break.configure( + text=f"Time until break: {model.remaining_working_time_to_display}" + ) + self.btn_change_suspended_state.configure( + text="Protection active", + text_color="GreenYellow", + image=customtkinter.CTkImage( + light_image=self.view.image_protection_active, size=(30, 30) + ), + require_redraw=True, + ) + if self.btn_take_break.cget("state") != "normal": + self.btn_take_break.configure(state="normal") + + if self.btn_change_suspended_state.cget("state") != "normal": + self.btn_change_suspended_state.configure(state="normal") diff --git a/tests/data/settings_invalid_not_int.json b/tests/data/settings_invalid_not_int.json new file mode 100644 index 0000000..eae4d8d --- /dev/null +++ b/tests/data/settings_invalid_not_int.json @@ -0,0 +1,6 @@ +{ + "work_duration": "asd", + "break_duration": "1", + "sounds": "on", + "notifications": "off" +} \ No newline at end of file diff --git a/tests/data/settings_invalid_not_invalid_json.json b/tests/data/settings_invalid_not_invalid_json.json new file mode 100644 index 0000000..14e86ce --- /dev/null +++ b/tests/data/settings_invalid_not_invalid_json.json @@ -0,0 +1,6 @@ +{ + "work_duration": asd", + "break_duration": "1", + "sounds": "on", + "notifications": "off" +} \ No newline at end of file diff --git a/tests/data/settings_invalid_not_string.json b/tests/data/settings_invalid_not_string.json new file mode 100644 index 0000000..40491f3 --- /dev/null +++ b/tests/data/settings_invalid_not_string.json @@ -0,0 +1,6 @@ +{ + "work_duration": 0, + "break_duration": "1", + "sounds": 123, + "notifications": "off" +} \ No newline at end of file diff --git a/tests/data/settings_valid_empty.json b/tests/data/settings_valid_empty.json new file mode 100644 index 0000000..0e0dcd2 --- /dev/null +++ b/tests/data/settings_valid_empty.json @@ -0,0 +1,3 @@ +{ + +} \ No newline at end of file diff --git a/tests/data/settings_valid_max_values.json b/tests/data/settings_valid_max_values.json new file mode 100644 index 0000000..c1592ab --- /dev/null +++ b/tests/data/settings_valid_max_values.json @@ -0,0 +1,6 @@ +{ + "work_duration": 100, + "break_duration": 100, + "sounds": "on", + "notifications": "on" +} \ No newline at end of file diff --git a/tests/data/settings_valid_mean_values.json b/tests/data/settings_valid_mean_values.json new file mode 100644 index 0000000..11a9b0d --- /dev/null +++ b/tests/data/settings_valid_mean_values.json @@ -0,0 +1,6 @@ +{ + "work_duration": 50, + "break_duration": 50, + "sounds": "off", + "notifications": "on" +} \ No newline at end of file diff --git a/tests/data/settings_valid_min_values.json b/tests/data/settings_valid_min_values.json new file mode 100644 index 0000000..4158230 --- /dev/null +++ b/tests/data/settings_valid_min_values.json @@ -0,0 +1,6 @@ +{ + "work_duration": 1, + "break_duration": 1, + "sounds": "off", + "notifications": "off" +} \ No newline at end of file diff --git a/tests/data/settings_valid_no_param.json b/tests/data/settings_valid_no_param.json new file mode 100644 index 0000000..01b38eb --- /dev/null +++ b/tests/data/settings_valid_no_param.json @@ -0,0 +1,5 @@ +{ +"work_duration": 1, +"sounds": "off", +"notifications": "off" +} \ No newline at end of file diff --git a/tests/data/settings_written_file.json b/tests/data/settings_written_file.json new file mode 100644 index 0000000..3e7abae --- /dev/null +++ b/tests/data/settings_written_file.json @@ -0,0 +1,7 @@ +{ + "work_duration": 45, + "break_duration": 15, + "sounds": "on", + "notifications": "on", + "protection_status": "on" +} \ No newline at end of file diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..131a87e --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,97 @@ +import sys + +sys.path.insert(0, "./src") + +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from src.settings import Settings, UserSettingsData + +settings_default_str = "{'break_duration': 15, 'notifications': 'on', 'protection_status': 'on', 'sounds': 'on', 'work_duration': 45}" + + +@pytest.mark.parametrize( + "settings_file_name, expected_settings_str", + [ + ( + "tests/data/settings_valid_min_values.json", + "{'break_duration': 1, 'notifications': 'off', 'protection_status': 'on', 'sounds': 'off', 'work_duration': 1}", + ), + ( + "tests/data/settings_valid_mean_values.json", + "{'break_duration': 50, 'notifications': 'on', 'protection_status': 'on', 'sounds': 'off', 'work_duration': 50}", + ), + ( + "tests/data/settings_valid_max_values.json", + "{'break_duration': 100, 'notifications': 'on', 'protection_status': 'on', 'sounds': 'on', 'work_duration': 100}", + ), + ( + "tests/data/settings_valid_empty.json", + "{'break_duration': 15, 'notifications': 'on', 'protection_status': 'on', 'sounds': 'on', 'work_duration': 45}", + ), + ( + "tests/data/settings_valid_no_param.json", + "{'break_duration': 15, 'notifications': 'off', 'protection_status': 'on', 'sounds': 'off', 'work_duration': 1}", + ), + ], +) +def test_read_settings_from_valid_file(settings_file_name, expected_settings_str): + settings = Settings(settings_file_name) + settings_str = str(settings) + print(settings_str) + assert settings_str == expected_settings_str + + +@pytest.mark.parametrize( + "settings_file_name", + [ + ("./adc.json"), + ("settings_invalid_not_invalid_json.json"), + ("settings_invalid_not_int.json"), + ("settings_invalid_not_string.json"), + ], +) +def test_read_settings_from_invalid_file(settings_file_name): + settings = Settings(settings_file_name) + settings_str = str(settings) + print(settings_str) + assert settings_str == settings_default_str + + +def test_write_default_user_settings_to_file(): + file_to_write = "tests/data/settings_written_file.json" + Path(file_to_write).unlink(missing_ok=True) + + user_settings = UserSettingsData() + settings = Settings(file_to_write) + settings.apply_settings_from_ui(new_settings_data=user_settings) + + settings.save_settings_to_file() + + written_file_content = Path(file_to_write).read_text() + print(f"Written file content = {written_file_content}") + + expected_file_content = '{\n "work_duration": 45,\n "break_duration": 15,\n "sounds": "on",\n "notifications": "on",\n "protection_status": "on"\n}' + assert written_file_content == expected_file_content, "Written content and expected content are not same!" + + +def test_write_invalid_settings_to_file(): + file_to_write = "tests/data/settings_written_file.json" + Path(file_to_write).unlink(missing_ok=True) + + user_settings = UserSettingsData() + user_settings.work_duration = 111 + user_settings.break_duration = 91 + user_settings.sounds = "off" + user_settings.notifications = "off" + settings = Settings(file_to_write) + settings.apply_settings_from_ui(new_settings_data=user_settings) + + settings.save_settings_to_file() + + written_file_content = Path(file_to_write).read_text() + print(f"Written file content = {written_file_content}") + expected_file_content = '{\n "work_duration": 45,\n "break_duration": 15,\n "sounds": "on",\n "notifications": "on",\n "protection_status": "on"\n}' + assert written_file_content == expected_file_content, "Written content and expected content are not same!"