/* * MIT License * * Copyright (c) 2023-2025 Konrad Rieck * * Permission is hereby granted, free of charge, to any person obtaining a * copy of this software and associated documentation files (the * "Software"), to deal in the Software without restriction, including * without limitation the rights to use, copy, modify, merge, publish, * distribute, sublicense, and/or sell copies of the Software, and to permit * persons to whom the Software is furnished to do so, subject to the * following conditions: * * The above copyright notice and this permission notice shall be included * in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT * OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR * THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ /* * # Deadline Face * * This is a watch face for tracking deadlines. It draws inspiration from * other watch faces of the project but focuses on keeping track of * deadlines. You can enter and monitor up to four different deadlines by * providing their respective date and time. The face has two modes: * *running mode* and *settings mode*. * * ## Running Mode * * When the watch face is activated, it defaults to running mode. The top * right corner shows the current deadline number, and the main display * presents the time left until the deadline. The format of the display * varies depending on the remaining time. * * - When less than a day is left, the display shows the remaining hours, * minutes, and seconds in the form `HH:MM:SS`. * * - When less than a month is left, the display shows the remaining days * and hours in the form `DD:HH` with the unit `dy` for days. * * - When less than a year is left, the display shows the remaining months * and days in the form `MM:DD` with the unit `mo` for months. * * - When more than a year is left, the years and months are displayed in * the form `YY:MM` with the unit `yr` for years. * * - When a deadline has passed in the last 24 hours, the display shows * `over` to indicate that the deadline has just recently been reached. * * - When no deadline is set for a particular slot, or if a deadline has * already passed by more than 24 hours, `--:--` is displayed. * * The user can navigate in running mode using the following buttons: * * - The *alarm button* moves the next deadline. There are currently four * slots available for deadlines. When the last slot has been reached, * pressing the button moves to the first slot. * * - A *long press* on the *alarm button* activates settings mode and * enables configuring the currently selected deadline. * * - A *long press* on the *light button* activates a deadline alarm. The * bell icon is displayed, and the alarm will ring upon reaching any of * the deadlines set. It is important to note that the watch will not * enter low-energy sleep mode while the alarm is enabled. * * * ## Settings Mode * * In settings mode, the currently selected slot for a deadline can be * configured by providing the date and the time. Like running mode, the * top right corner of the display indicates the current deadline number. * The main display shows the date and, on the next page, the time to be * configured. * * The user can use the following buttons in settings mode. * * - The *light button* navigates through the different date and time * settings, going from year, month, day, hour, to minute. The selected * position is blinking. * * - A *long press* on the light button resets the date and time to the next * day at midnight. This is the default deadline. * * - The *alarm button* increments the currently selected position. A *long * press* on the *alarm button* changes the value faster. * * - The *mode button* exists setting mode and returns to *running mode*. * Here the selected deadline slot can be changed. * */ #include #include #include "deadline_face.h" #include "watch.h" #include "watch_utility.h" /* Beep types */ typedef enum { BEEP_BUTTON, BEEP_ENABLE, BEEP_DISABLE } beep_type_t; #define SETTINGS_NUM (5) const char settings_titles[SETTINGS_NUM][6] = { "Year ", "Month", "Day ", "Hour ", "Minut" }; const char settings_fallback_titles[SETTINGS_NUM][3] = { "YR", "MO", "DA", "HR", "M1" }; const char *running_title = "DUE"; const char *running_fallback_title = "DL"; /* Local functions */ static void _deadline_running_init(deadline_state_t * state); static bool _deadline_running_loop(movement_event_t event, void *context); static void _deadline_running_display(movement_event_t event, deadline_state_t * state); static void _deadline_settings_init(deadline_state_t * state); static bool _deadline_settings_loop(movement_event_t event, void *context); static void _deadline_settings_display(movement_event_t event, deadline_state_t * state, watch_date_time_t date); /* Check for leap year */ static inline bool _is_leap(int16_t y) { y += 1900; return !(y % 4) && ((y % 100) || !(y % 400)); } /* Modulo function */ static inline unsigned int _mod(int a, int b) { int r = a % b; return r < 0 ? r + b : r; } /* Return days in month */ static inline int _days_in_month(int16_t month, int16_t year) { uint8_t days[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; month = _mod(month - 1, 12); if (month == 1 && _is_leap(year)) { return days[month] + 1; } else { return days[month]; } } /* Play beep sound based on type */ static inline void _beep(beep_type_t beep_type) { if (!movement_button_should_sound()) return; switch (beep_type) { case BEEP_BUTTON: watch_buzzer_play_note_with_volume(BUZZER_NOTE_C7, 50, movement_button_volume()); break; case BEEP_ENABLE: watch_buzzer_play_note_with_volume(BUZZER_NOTE_G7, 50, movement_button_volume()); watch_buzzer_play_note(BUZZER_NOTE_REST, 75); watch_buzzer_play_note_with_volume(BUZZER_NOTE_C8, 50, movement_button_volume()); break; case BEEP_DISABLE: watch_buzzer_play_note_with_volume(BUZZER_NOTE_C8, 50, movement_button_volume()); watch_buzzer_play_note(BUZZER_NOTE_REST, 75); watch_buzzer_play_note_with_volume(BUZZER_NOTE_G7, 50, movement_button_volume()); break; } } /* Change tick frequency */ static inline void _change_tick_freq(uint8_t freq, deadline_state_t *state) { if (state->tick_freq != freq) { movement_request_tick_frequency(freq); state->tick_freq = freq; } } /* Determine index of closest deadline */ static uint8_t _closest_deadline(deadline_state_t *state) { watch_date_time_t now = movement_get_local_date_time(); uint32_t now_ts = watch_utility_date_time_to_unix_time(now, 0); uint32_t min_ts = UINT32_MAX; uint8_t min_index = 0; for (uint8_t i = 0; i < DEADLINE_FACE_DATES; i++) { /* Skip expired deadlines and those further in the future than current minimum */ if (state->deadlines[i] < now_ts || state->deadlines[i] > min_ts) { continue; } min_ts = state->deadlines[i]; min_index = i; } return min_index; } /* Play background alarm */ static void _background_alarm_play(deadline_state_t *state) { movement_play_alarm(); movement_move_to_face(state->face_idx); } /* Reset deadline to tomorrow */ static inline void _reset_deadline(deadline_state_t *state) { /* Get current time and reset hours/minutes/seconds */ watch_date_time_t date_time = movement_get_local_date_time(); date_time.unit.second = 0; date_time.unit.minute = 0; date_time.unit.hour = 0; /* Add 24 hours to obtain first second of tomorrow */ uint32_t ts = watch_utility_date_time_to_unix_time(date_time, 0); ts += 24 * 60 * 60; state->deadlines[state->current_index] = ts; } /* Calculate the naive difference between deadline and current time */ static void _calculate_time_remaining(watch_date_time_t dl, watch_date_time_t now, int16_t *units) { units[0] = dl.unit.second - now.unit.second; units[1] = dl.unit.minute - now.unit.minute; units[2] = dl.unit.hour - now.unit.hour; units[3] = dl.unit.day - now.unit.day; units[4] = dl.unit.month - now.unit.month; units[5] = dl.unit.year - now.unit.year; } /* Format the remaining time for display */ static void _format_time_remaining(int16_t *units, char *buffer, size_t buffer_size) { const int16_t years = units[5]; const int16_t months = units[4]; const int16_t days = units[3]; const int16_t hours = units[2]; const int16_t minutes = units[1]; const int16_t seconds = units[0]; if (years > 0) { /* years:months */ snprintf(buffer, buffer_size, "%02d%02dYR", years % 100, months % 12); } else if (months > 0) { /* months:days */ snprintf(buffer, buffer_size, "%02d%02dMO", (years * 12 + months) % 100, days % 32); } else if (days > 0) { /* days:hours */ snprintf(buffer, buffer_size, "%02d%02ddY", days % 32, hours % 24); } else { /* hours:minutes:seconds */ snprintf(buffer, buffer_size, "%02d%02d%02d", hours % 24, minutes % 60, seconds % 60); } } /* Correct the naive time difference calculation */ static void _correct_time_difference(int16_t *units, watch_date_time_t deadline) { const uint8_t range[] = { 60, 60, 24, 30, 12, 0 }; for (uint8_t i = 0; i < 6; i++) { if (units[i] < 0) { /* Correct remaining units */ if (i == 3) { units[i] += _days_in_month(deadline.unit.month - 1, deadline.unit.year); } else { units[i] += range[i]; } /* Carry over change to next unit if non-zero */ if (i < 5 && units[i + 1] != 0) { units[i + 1] -= 1; } } } } /* Increment date in settings mode. Function taken from `set_time_face.c` */ static void _increment_date(deadline_state_t *state, watch_date_time_t date_time) { const uint8_t days_in_month[12] = { 31, 28, 31, 30, 31, 30, 30, 31, 30, 31, 30, 31 }; switch (state->current_page) { case 0: /* Only 10 years covered. Fix this sometime next decade */ date_time.unit.year = ((date_time.unit.year % 10) + 1); break; case 1: date_time.unit.month = (date_time.unit.month % 12) + 1; break; case 2: date_time.unit.day = date_time.unit.day + 1; /* Check for leap years */ int8_t days = days_in_month[date_time.unit.month - 1]; if (date_time.unit.month == 2 && _is_leap(date_time.unit.year)) days++; if (date_time.unit.day > days) date_time.unit.day = 1; break; case 3: date_time.unit.hour = (date_time.unit.hour + 1) % 24; break; case 4: date_time.unit.minute = (date_time.unit.minute + 1) % 60; break; } uint32_t ts = watch_utility_date_time_to_unix_time(date_time, 0); state->deadlines[state->current_index] = ts; } /* Update display in running mode */ static void _deadline_running_display(movement_event_t event, deadline_state_t *state) { (void) event; /* Seconds, minutes, hours, days, months, years */ int16_t units[] = { 0, 0, 0, 0, 0, 0 }; char buf[16]; /* Top row with face name and deadline index */ watch_display_text_with_fallback(WATCH_POSITION_TOP_LEFT, running_title, running_fallback_title); sprintf(buf, "%2d", state->current_index + 1); watch_display_text_with_fallback(WATCH_POSITION_TOP_RIGHT, buf, buf); /* Display indicators */ if (state->alarm_enabled) watch_set_indicator(WATCH_INDICATOR_BELL); else watch_clear_indicator(WATCH_INDICATOR_BELL); watch_date_time_t now = movement_get_local_date_time(); uint32_t now_ts = watch_utility_date_time_to_unix_time(now, 0); /* Deadline expired */ if (state->deadlines[state->current_index] < now_ts) { if (state->deadlines[state->current_index] + 24 * 60 * 60 > now_ts) sprintf(buf, "OVER "); else sprintf(buf, "---- "); watch_display_text_with_fallback(WATCH_POSITION_BOTTOM, buf, buf); return; } /* Get date time structs */ uint32_t dl_ts = state->deadlines[state->current_index]; watch_date_time_t deadline = watch_utility_date_time_from_unix_time(dl_ts, 0); /* Calculate and format time remaining */ _calculate_time_remaining(deadline, now, units); _correct_time_difference(units, deadline); _format_time_remaining(units, buf, sizeof(buf)); watch_display_text_with_fallback(WATCH_POSITION_BOTTOM, buf, buf); } /* Init running mode */ static void _deadline_running_init(deadline_state_t *state) { (void) state; watch_clear_indicator(WATCH_INDICATOR_24H); watch_clear_indicator(WATCH_INDICATOR_PM); watch_set_colon(); /* Ensure 1Hz updates only */ _change_tick_freq(1, state); } /* Loop of running mode */ static bool _deadline_running_loop(movement_event_t event, void *context) { deadline_state_t *state = (deadline_state_t *) context; if (event.event_type != EVENT_BACKGROUND_TASK) _deadline_running_display(event, state); switch (event.event_type) { case EVENT_ALARM_BUTTON_UP: _beep(BEEP_BUTTON); state->current_index = (state->current_index + 1) % DEADLINE_FACE_DATES; _deadline_running_display(event, state); break; case EVENT_ALARM_LONG_PRESS: _beep(BEEP_ENABLE); _deadline_settings_init(state); state->mode = DEADLINE_SETTINGS; break; case EVENT_MODE_BUTTON_UP: movement_move_to_next_face(); return false; case EVENT_LIGHT_BUTTON_DOWN: break; case EVENT_LIGHT_LONG_PRESS: _beep(BEEP_BUTTON); state->alarm_enabled = !state->alarm_enabled; _deadline_running_display(event, state); break; case EVENT_TIMEOUT: movement_move_to_face(0); break; case EVENT_BACKGROUND_TASK: _background_alarm_play(state); break; case EVENT_LOW_ENERGY_UPDATE: break; default: return movement_default_loop_handler(event); } return true; } /* Update display in settings mode */ static void _deadline_settings_display(movement_event_t event, deadline_state_t *state, watch_date_time_t date_time) { char buf[7]; watch_display_text_with_fallback(WATCH_POSITION_TOP, settings_titles[state->current_page], settings_fallback_titles[state->current_page]); if (state->current_page > 2) { /* Time settings */ watch_set_colon(); if (movement_clock_mode_24h() == MOVEMENT_CLOCK_MODE_24H) { /* 24h format */ watch_set_indicator(WATCH_INDICATOR_24H); sprintf(buf, "%2d%02d ", date_time.unit.hour, date_time.unit.minute); } else { /* 12h format */ if (date_time.unit.hour < 12) watch_clear_indicator(WATCH_INDICATOR_PM); else watch_set_indicator(WATCH_INDICATOR_PM); uint8_t hour = date_time.unit.hour % 12; sprintf(buf, "%2d%02d ", hour ? hour : 12, date_time.unit.minute); } } else { /* Date settings */ watch_clear_colon(); watch_clear_indicator(WATCH_INDICATOR_24H); watch_clear_indicator(WATCH_INDICATOR_PM); sprintf(buf, "%2d%02d%02d", date_time.unit.year + 20, date_time.unit.month, date_time.unit.day); } /* Blink up the parameter we are setting */ if (event.subsecond % 2) { switch (state->current_page) { case 0: case 3: buf[0] = buf[1] = ' '; break; case 1: case 4: buf[2] = buf[3] = ' '; break; case 2: buf[4] = buf[5] = ' '; break; } } watch_display_text_with_fallback(WATCH_POSITION_BOTTOM, buf, buf); } /* Init setting mode */ static void _deadline_settings_init(deadline_state_t *state) { state->current_page = 0; /* Init fresh deadline to next day */ if (state->deadlines[state->current_index] == 0) { _reset_deadline(state); } /* Ensure 1Hz updates only */ _change_tick_freq(1, state); } /* Loop of setting mode */ static bool _deadline_settings_loop(movement_event_t event, void *context) { deadline_state_t *state = (deadline_state_t *) context; watch_date_time_t date_time; date_time = watch_utility_date_time_from_unix_time(state->deadlines[state->current_index], 0); if (event.event_type != EVENT_BACKGROUND_TASK) _deadline_settings_display(event, state, date_time); switch (event.event_type) { case EVENT_TICK: if (state->tick_freq == 8) { if (HAL_GPIO_BTN_ALARM_read()) { _increment_date(state, date_time); _deadline_settings_display(event, state, date_time); } else { _change_tick_freq(4, state); } } break; case EVENT_ALARM_LONG_PRESS: _change_tick_freq(8, state); break; case EVENT_ALARM_LONG_UP: _change_tick_freq(4, state); break; case EVENT_LIGHT_LONG_PRESS: _beep(BEEP_BUTTON); _reset_deadline(state); break; case EVENT_LIGHT_BUTTON_DOWN: break; case EVENT_LIGHT_BUTTON_UP: state->current_page = (state->current_page + 1) % SETTINGS_NUM; _deadline_settings_display(event, state, date_time); break; case EVENT_ALARM_BUTTON_UP: _change_tick_freq(4, state); _increment_date(state, date_time); _deadline_settings_display(event, state, date_time); break; case EVENT_TIMEOUT: _beep(BEEP_BUTTON); _change_tick_freq(1, state); state->mode = DEADLINE_RUNNING; movement_move_to_face(0); break; case EVENT_MODE_BUTTON_UP: _beep(BEEP_DISABLE); _deadline_running_init(state); _deadline_running_display(event, state); state->mode = DEADLINE_RUNNING; break; case EVENT_BACKGROUND_TASK: _background_alarm_play(state); break; default: return movement_default_loop_handler(event); } return true; } /* Setup face */ void deadline_face_setup(uint8_t watch_face_index, void **context_ptr) { (void) watch_face_index; if (*context_ptr != NULL) return; /* Skip setup if context available */ /* Allocate state */ *context_ptr = malloc(sizeof(deadline_state_t)); memset(*context_ptr, 0, sizeof(deadline_state_t)); /* Store face index for background tasks */ deadline_state_t *state = (deadline_state_t *) * context_ptr; state->face_idx = watch_face_index; } /* Activate face */ void deadline_face_activate(void *context) { deadline_state_t *state = (deadline_state_t *) context; /* Set display options */ _deadline_running_init(state); state->mode = DEADLINE_RUNNING; state->current_index = _closest_deadline(state); } /* Loop face */ bool deadline_face_loop(movement_event_t event, void *context) { deadline_state_t *state = (deadline_state_t *) context; switch (state->mode) { case DEADLINE_SETTINGS: _deadline_settings_loop(event, context); break; default: case DEADLINE_RUNNING: _deadline_running_loop(event, context); break; } return true; } /* Resign face */ void deadline_face_resign(void *context) { (void) context; } /* Background task */ movement_watch_face_advisory_t deadline_face_advise(void *context) { deadline_state_t *state = (deadline_state_t *) context; movement_watch_face_advisory_t retval = { 0 }; if (!state->alarm_enabled) return retval; /* Determine closest deadline */ watch_date_time_t now = movement_get_local_date_time(); uint32_t now_ts = watch_utility_date_time_to_unix_time(now, 0); uint32_t next_ts = state->deadlines[_closest_deadline(state)]; /* No active deadline */ if (next_ts < now_ts) return retval; /* No deadline within next 60 seconds */ if (next_ts >= now_ts + 60) return retval; /* Deadline within next minute. Let's set up an alarm */ retval.wants_background_task = true; return retval; }