Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: show native OS notifications #46

Merged
merged 4 commits into from
Sep 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion focus_time_app/cli/commands/configuration_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand All @@ -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()

Expand Down Expand Up @@ -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")
26 changes: 23 additions & 3 deletions focus_time_app/cli/commands/sync_command.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import logging
import time
from _zoneinfo import ZoneInfo
from datetime import datetime

import typer

Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion focus_time_app/configuration/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
17 changes: 17 additions & 0 deletions focus_time_app/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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'
17 changes: 17 additions & 0 deletions focus_time_app/utils/os_notification/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
"""
13 changes: 13 additions & 0 deletions focus_time_app/utils/os_notification/macos_os_notification.py
Original file line number Diff line number Diff line change
@@ -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}'")
11 changes: 11 additions & 0 deletions focus_time_app/utils/os_notification/win_os_notification.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
21 changes: 15 additions & 6 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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


Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions tests/end2end/test_start_stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down
8 changes: 3 additions & 5 deletions tests/end2end/test_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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)))
Expand Down
2 changes: 1 addition & 1 deletion tests/test_persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down