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!"