diff --git a/focus_time_app/cli/commands/configuration_command.py b/focus_time_app/cli/commands/configuration_command.py index eca3591..b004519 100644 --- a/focus_time_app/cli/commands/configuration_command.py +++ b/focus_time_app/cli/commands/configuration_command.py @@ -15,6 +15,7 @@ from focus_time_app.focus_time_calendar.adapter_factory import create_calendar_adapter from focus_time_app.focus_time_calendar.event import CalendarType from focus_time_app.utils import is_production_environment +from focus_time_app.utils.os_notification import OsNativeNotificationImpl class ConfigurationCommand: @@ -53,7 +54,8 @@ def run(self): configuration = ConfigurationV1(calendar_type=calendar_type, calendar_look_ahead_hours=0, calendar_look_back_hours=0, focustime_event_name="Foo", start_commands=[], stop_commands=[], dnd_profile_name="foo", - set_event_reminder=False, event_reminder_time_minutes=0) + set_event_reminder=False, event_reminder_time_minutes=0, + show_notification=False) calendar_adapter = create_calendar_adapter(configuration) while True: adapter_configuration = calendar_adapter.authenticate() @@ -74,6 +76,8 @@ def run(self): self._configure_event_reminders(configuration) + self._configure_notification(configuration) + if not self._skip_install_dnd_helper: CommandExecutorImpl.install_dnd_helpers() @@ -158,3 +162,16 @@ def _configure_event_reminders(configuration: ConfigurationV1): "reminder be shown?", type=int, default=15, prompt_suffix='\n') if configuration.event_reminder_time_minutes > 0: break + + @staticmethod + def _configure_notification(configuration: ConfigurationV1): + prompt_message = "Do you want the Focus Time app to show a system notification whenever a focus time " \ + "starts or stops? If you choose yes, a test notification will be shown." + if sys.platform == "darwin": + prompt_message += " If you configure this app for the first time, macOS will request from you to allow " \ + "notifications (see top right corner of your screen)." + + configuration.show_notification = typer.confirm(prompt_message, default=True, prompt_suffix='\n') + + if configuration.show_notification: + OsNativeNotificationImpl.send_notification("Focus Time App test", "This is a dummy notification") diff --git a/focus_time_app/cli/commands/sync_command.py b/focus_time_app/cli/commands/sync_command.py index a602421..fc5b315 100644 --- a/focus_time_app/cli/commands/sync_command.py +++ b/focus_time_app/cli/commands/sync_command.py @@ -1,4 +1,7 @@ import logging +import time +from _zoneinfo import ZoneInfo +from datetime import datetime import typer @@ -8,6 +11,10 @@ from focus_time_app.focus_time_calendar.abstract_calendar_adapter import AbstractCalendarAdapter from focus_time_app.focus_time_calendar.event import FocusTimeEvent from focus_time_app.focus_time_calendar.utils import get_active_focustime_event +from focus_time_app.utils import human_readable_timedelta +from focus_time_app.utils.os_notification import OsNativeNotificationImpl + +NOTIFICATION_TIME_FORMAT = "%H:%M" class SyncCommand: @@ -20,13 +27,21 @@ def run(self): events = self._calendar_adapter.get_events() self._adjust_reminder_time_if_necessary(events) marker_file_exists = Persistence.ongoing_focustime_markerfile_exists() - if get_active_focustime_event(events): + if active_focustime := get_active_focustime_event(events): if marker_file_exists: msg = "Focus time is already active. Exiting ..." typer.echo(msg) self._logger.info(msg) else: - msg = "Found a new focus time, calling start command(s) ..." + if self._configuration.show_notification: + title = "Focus time starts now" + remaining_minutes = human_readable_timedelta(active_focustime.end - datetime.now(ZoneInfo('UTC'))) + message = f"Time remaining: {remaining_minutes} " \ + f"(until {active_focustime.end.astimezone().strftime(NOTIFICATION_TIME_FORMAT)})" + OsNativeNotificationImpl.send_notification(title, message) + + msg = f"Found a new focus time (from {active_focustime.start} to {active_focustime.end}), " \ + f"calling start command(s) ..." typer.echo(msg) self._logger.info(msg) try: @@ -37,12 +52,17 @@ def run(self): else: if marker_file_exists: - msg = "No focus time is active, calling stop command(s) ..." + msg = "No focus time is active anymore, calling stop command(s) ..." typer.echo(msg) self._logger.info(msg) try: CommandExecutorImpl.execute_commands(self._configuration.stop_commands, self._configuration.dnd_profile_name) + + if self._configuration.show_notification: + title = "Focus time has ended" + message = "Your configured stop-commands have been called" + OsNativeNotificationImpl.send_notification(title, message) finally: Persistence.set_ongoing_focustime(ongoing=False) else: diff --git a/focus_time_app/configuration/configuration.py b/focus_time_app/configuration/configuration.py index 203212c..7c0bae5 100644 --- a/focus_time_app/configuration/configuration.py +++ b/focus_time_app/configuration/configuration.py @@ -34,8 +34,9 @@ class ConfigurationV1: dnd_profile_name: str = field(metadata={"validate": marshmallow.validate.Length(min=1)}) set_event_reminder: bool event_reminder_time_minutes: int = field(metadata={"validate": marshmallow.validate.Range(min=0)}) - version: int = field(default=1) + show_notification: bool = field(default=False) adapter_configuration: Optional[Dict[str, Any]] = field(default=None) + version: int = field(default=1) ConfigurationV1Schema = marshmallow_dataclass.class_schema(ConfigurationV1) diff --git a/focus_time_app/utils/__init__.py b/focus_time_app/utils/__init__.py index 4af8211..1b1712e 100644 --- a/focus_time_app/utils/__init__.py +++ b/focus_time_app/utils/__init__.py @@ -1,4 +1,5 @@ import sys +from datetime import timedelta from os import getenv # keep the values in sync with env vars set in the CI/CD YAML (workflow) files @@ -23,3 +24,19 @@ def get_environment_suffix() -> str: if is_production_environment(): return "" return "-dev" + + +def human_readable_timedelta(duration: timedelta) -> str: + data = {} + data['days'], remaining = divmod(duration.total_seconds(), 86_400) + data['hours'], remaining = divmod(remaining, 3_600) + data['minutes'], data['seconds'] = divmod(remaining, 60) + + del data['seconds'] # we do not need this degree of precision + + time_parts = ((name, round(value)) for name, value in data.items()) + time_parts = [f'{value} {name[:-1] if value == 1 else name}' for name, value in time_parts if value > 0] + if time_parts: + return ' '.join(time_parts) + else: + return 'below 1 second' diff --git a/focus_time_app/utils/os_notification/__init__.py b/focus_time_app/utils/os_notification/__init__.py new file mode 100644 index 0000000..84c9b58 --- /dev/null +++ b/focus_time_app/utils/os_notification/__init__.py @@ -0,0 +1,17 @@ +import sys +from typing import Optional + +from focus_time_app.utils.os_notification.abstract_os_notification import AbstractOsNotification + +OsNativeNotificationImpl: Optional[AbstractOsNotification] = None + +if sys.platform == "win32": + from focus_time_app.utils.os_notification.win_os_notification import WinOsNotification + + OsNativeNotificationImpl = WinOsNotification() +elif sys.platform == "darwin": + from focus_time_app.utils.os_notification.macos_os_notification import MacosOsNotification + + OsNativeNotificationImpl = MacosOsNotification() +else: + raise NotImplementedError diff --git a/focus_time_app/utils/os_notification/abstract_os_notification.py b/focus_time_app/utils/os_notification/abstract_os_notification.py new file mode 100644 index 0000000..e8f9a6e --- /dev/null +++ b/focus_time_app/utils/os_notification/abstract_os_notification.py @@ -0,0 +1,8 @@ +from abc import ABC + + +class AbstractOsNotification(ABC): + def send_notification(self, title: str, message: str): + """ + Shows an OS-native notification to the user + """ diff --git a/focus_time_app/utils/os_notification/macos_os_notification.py b/focus_time_app/utils/os_notification/macos_os_notification.py new file mode 100644 index 0000000..d9ecca6 --- /dev/null +++ b/focus_time_app/utils/os_notification/macos_os_notification.py @@ -0,0 +1,13 @@ +# from mac_notifications import client +import os + +from focus_time_app.utils.os_notification import AbstractOsNotification + + +class MacosOsNotification(AbstractOsNotification): + def send_notification(self, title: str, message: str): + # client.create_notification(title=title, text=message) # Note: we could also set an icon, if we wanted to + # TODO: replace this approach with https://pypi.org/project/macos-notifications + # once https://github.com/Jorricks/macos-notifications/issues/12 is resolved + appleScriptNotification = f'display notification "{message}" with title "{title}"' + os.system(f"osascript -e '{appleScriptNotification}'") diff --git a/focus_time_app/utils/os_notification/win_os_notification.py b/focus_time_app/utils/os_notification/win_os_notification.py new file mode 100644 index 0000000..a976a88 --- /dev/null +++ b/focus_time_app/utils/os_notification/win_os_notification.py @@ -0,0 +1,11 @@ +from windows_toasts import WindowsToaster, Toast + +from focus_time_app.utils.os_notification import AbstractOsNotification + + +class WinOsNotification(AbstractOsNotification): + def send_notification(self, title: str, message: str): + toaster = WindowsToaster('Focus Time App') + t = Toast() + t.text_fields = [title, message] + toaster.show_toast(t) diff --git a/requirements.txt b/requirements.txt index 8f9ef96..96a2907 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,5 @@ pytest==7.4.1 selenium==4.12.0 caldav==1.3.6 pwinput==1.0.3 +windows-toasts==1.0.0; platform_system == "Windows" +macos-notifications==0.1.6; platform_system == "Darwin" diff --git a/tests/conftest.py b/tests/conftest.py index c10e67e..32c988a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -46,8 +46,11 @@ def start_commands(tmp_path: Path) -> ConfiguredCommands: return ConfiguredCommands(commands=["dnd-start", f"echo start>> {verification_file_path}"], verification_file_path=verification_file_path) if sys.platform == "darwin": - return ConfiguredCommands(commands=[f"echo start >> {verification_file_path}"], - verification_file_path=verification_file_path) + commands = [f"echo start >> {verification_file_path}"] + if CommandExecutorImpl.is_dnd_helper_installed(): + commands.insert(0, "dnd-start") + + return ConfiguredCommands(commands=commands, verification_file_path=verification_file_path) raise NotImplementedError @@ -63,8 +66,11 @@ def stop_commands(tmp_path: Path) -> ConfiguredCommands: return ConfiguredCommands(commands=["dnd-stop", f"echo stop>> {verification_file_path}"], verification_file_path=verification_file_path) elif sys.platform == "darwin": - return ConfiguredCommands(commands=[f"echo stop >> {verification_file_path}"], - verification_file_path=verification_file_path) + commands = [f"echo stop >> {verification_file_path}"] + if CommandExecutorImpl.is_dnd_helper_installed(): + commands.insert(0, "dnd-stop") + + return ConfiguredCommands(commands=commands, verification_file_path=verification_file_path) raise NotImplementedError @@ -101,7 +107,7 @@ def configured_calendar_adapter(request, start_commands, stop_commands) -> Abstr focustime_event_name=focustime_event_name, start_commands=start_commands.commands, stop_commands=stop_commands.commands, dnd_profile_name=dnd_profile_name, set_event_reminder=True, - event_reminder_time_minutes=15) + event_reminder_time_minutes=15, show_notification=False) return get_configured_calendar_adapter_for_testing(config) @@ -121,7 +127,7 @@ def configured_cli(calendar_type: CalendarType, skip_background_scheduler_setup: focustime_event_name=focustime_event_name, start_commands=start_commands.commands, stop_commands=stop_commands.commands, dnd_profile_name=dnd_profile_name, set_event_reminder=True, - event_reminder_time_minutes=15) + event_reminder_time_minutes=15, show_notification=True) Persistence.get_config_file_path().unlink(missing_ok=True) @@ -167,6 +173,9 @@ def configured_cli(calendar_type: CalendarType, skip_background_scheduler_setup: out = config_process.stdout.readline() # asks for reminder minutes write_line_to_stream(config_process.stdin, config.event_reminder_time_minutes) + out = config_process.stdout.readline() # asks whether to show a notification + write_line_to_stream(config_process.stdin, "y" if config.show_notification else "n") + if sys.platform == "win32": out = config_process.stdout.readline() # asks for name of Windows Focus Assist profile write_line_to_stream(config_process.stdin, config.dnd_profile_name) diff --git a/tests/end2end/test_start_stop.py b/tests/end2end/test_start_stop.py index 880c9a0..5f7e7e5 100644 --- a/tests/end2end/test_start_stop.py +++ b/tests/end2end/test_start_stop.py @@ -30,7 +30,7 @@ def test_start_stop_simple(self, configured_cli_no_bg_jobs: ConfiguredCLI): focus_time_duration_minutes = 2 output = run_cli_command_handle_output_error("start", additional_args=[str(focus_time_duration_minutes)]) - assert output.startswith("Found a new focus time, calling start command(s) ...") + assert "calling start command(s) ..." in output if CommandExecutorImpl.is_dnd_helper_installed(): assert CommandExecutorImpl.is_dnd_active() @@ -56,7 +56,7 @@ def test_start_stop_simple(self, configured_cli_no_bg_jobs: ConfiguredCLI): # Run the "stop" command, verify that DND is then disabled output = run_cli_command_handle_output_error("stop") - assert output.startswith("No focus time is active, calling stop command(s) ...") + assert output.startswith("No focus time is active anymore, calling stop command(s) ...") if CommandExecutorImpl.is_dnd_helper_installed(): assert not CommandExecutorImpl.is_dnd_active() diff --git a/tests/end2end/test_sync.py b/tests/end2end/test_sync.py index 4f3baaa..b0164a7 100644 --- a/tests/end2end/test_sync.py +++ b/tests/end2end/test_sync.py @@ -49,8 +49,7 @@ def test_manual_on_off_sync(self, configured_cli_no_bg_jobs: ConfiguredCLI): time.sleep(60) # run sync, should enable DND - assert run_cli_command_handle_output_error("sync").startswith( - "Found a new focus time, calling start command(s) ...") + assert "calling start command(s) ..." in run_cli_command_handle_output_error("sync") if CommandExecutorImpl.is_dnd_helper_installed(): assert CommandExecutorImpl.is_dnd_active() assert configured_cli_no_bg_jobs.verification_file_path.read_text() == "start\n" @@ -67,7 +66,7 @@ def test_manual_on_off_sync(self, configured_cli_no_bg_jobs: ConfiguredCLI): # run sync again, DND should be turned off assert run_cli_command_handle_output_error("sync").startswith( - "No focus time is active, calling stop command(s) ...") + "No focus time is active anymore, calling stop command(s) ...") if CommandExecutorImpl.is_dnd_helper_installed(): assert not CommandExecutorImpl.is_dnd_active() assert configured_cli_no_bg_jobs.verification_file_path.read_text() == "start\nstop\n" @@ -143,8 +142,7 @@ def test_reminder(self, configured_cli_no_bg_jobs: ConfiguredCLI): assert events[0].reminder_in_minutes == new_event_reminder_time_minutes # The sync call should set the event reminder time - assert run_cli_command_handle_output_error("sync").startswith( - "Found a new focus time, calling start command(s) ...") + assert "calling start command(s) ..." in run_cli_command_handle_output_error("sync") events = configured_cli_no_bg_jobs.calendar_adapter.get_events((now - timedelta(minutes=1), now + timedelta(minutes=2))) diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 3f9451b..5ff0f62 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -18,7 +18,7 @@ def test_store_load_configuration(self): config = ConfigurationV1(calendar_type=CalendarType.Outlook365, calendar_look_ahead_hours=3, calendar_look_back_hours=5, focustime_event_name="ft", start_commands=["start"], stop_commands=["stop"], dnd_profile_name="dnd", set_event_reminder=True, - event_reminder_time_minutes=15) + event_reminder_time_minutes=15, show_notification=False) Persistence.store_configuration(config) config_loaded = Persistence.load_configuration()