/* * MIT License * * Copyright (c) 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. */ #include #include #include "hydration_face.h" #include "watch_slcd.h" #include "watch_utility.h" /* Display frequency */ #define DISPLAY_FREQUENCY 1 /* Maximum possible water intake (9.9l) */ #define MAX_WATER_INTAKE 9900 /* Settings */ #define NUM_SETTINGS 5 /* Default values */ #define DEFAULT_WATER_GLASS_ML 100 #define DEFAULT_WATER_GOAL_ML 1600 #define DEFAULT_WAKE_HOUR 7 #define DEFAULT_SLEEP_HOUR 22 #define DEFAULT_ALERT_INTERVAL 2 static void _display_water_ml(uint16_t water_ml) { char buf[10], *unit = "ml"; if (watch_get_lcd_type() != WATCH_LCD_TYPE_CUSTOM) { unit = "nl"; /* nl looks more like ml on the original LCD */ } snprintf(buf, sizeof(buf), "%4d%s", water_ml, unit); watch_display_text_with_fallback(WATCH_POSITION_BOTTOM, buf, buf); } static void _settings_title_display(hydration_state_t *state, char *buf1, char *buf2) { char buf[10]; watch_display_text_with_fallback(WATCH_POSITION_TOP, buf1, buf2); if (watch_get_lcd_type() != WATCH_LCD_TYPE_CUSTOM) { snprintf(buf, sizeof(buf), "%2d", state->settings_page + 1); watch_display_text_with_fallback(WATCH_POSITION_TOP_RIGHT, buf, buf); } if (state->alert_enabled) { watch_set_indicator(WATCH_INDICATOR_SIGNAL); } else { watch_clear_indicator(WATCH_INDICATOR_SIGNAL); } } static bool _settings_blink(uint8_t subsecond) { if (subsecond % 2 == 0) { watch_display_text_with_fallback(WATCH_POSITION_BOTTOM, " ", " "); return true; } return false; } static void _settings_water_glass_display(void *context, uint8_t subsecond) { hydration_state_t *state = (hydration_state_t *) context; _settings_title_display(state, "GLASS", "GL"); if (_settings_blink(subsecond)) return; /* First setting so clear colon and indicators */ watch_clear_colon(); watch_clear_indicator(WATCH_INDICATOR_24H); watch_clear_indicator(WATCH_INDICATOR_PM); _display_water_ml(state->water_glass); } static void _settings_water_glass_advance(void *context) { hydration_state_t *state = (hydration_state_t *) context; state->water_glass += 100; if (state->water_glass > 1000) { state->water_glass = 100; } } static void _settings_water_goal_display(void *context, uint8_t subsecond) { hydration_state_t *state = (hydration_state_t *) context; _settings_title_display(state, "GOAL ", "GO"); if (_settings_blink(subsecond)) return; _display_water_ml(state->water_goal); } static void _settings_water_goal_advance(void *context) { hydration_state_t *state = (hydration_state_t *) context; state->water_goal += state->water_glass; if (state->water_goal > 3000) { state->water_goal = 100; } } static void _settings_wake_time_display(void *context, uint8_t subsecond) { char buf[10]; hydration_state_t *state = (hydration_state_t *) context; _settings_title_display(state, "WAKE ", "WK"); if (_settings_blink(subsecond)) return; /* Start of time settings, set colon and check 24h indicator */ watch_set_colon(); uint8_t hour = state->wake_hour; if (movement_clock_mode_24h()) { watch_set_indicator(WATCH_INDICATOR_24H); } else { if (state->wake_hour > 12) { watch_set_indicator(WATCH_INDICATOR_PM); hour = hour % 12; } else { watch_clear_indicator(WATCH_INDICATOR_PM); } } snprintf(buf, sizeof(buf), "%02d00", hour); watch_display_text_with_fallback(WATCH_POSITION_BOTTOM, buf, buf); } static void _settings_wake_time_advance(void *context) { hydration_state_t *state = (hydration_state_t *) context; state->wake_hour += 1; if (state->wake_hour >= 24) { state->wake_hour = 0; } } static void _settings_sleep_time_display(void *context, uint8_t subsecond) { char buf[10]; hydration_state_t *state = (hydration_state_t *) context; _settings_title_display(state, "SLEEP", "SL"); if (_settings_blink(subsecond)) return; uint8_t hour = state->sleep_hour; if (movement_clock_mode_24h()) { watch_set_indicator(WATCH_INDICATOR_24H); } else { if (state->sleep_hour >= 12) { watch_set_indicator(WATCH_INDICATOR_PM); hour = hour % 12; } else { watch_clear_indicator(WATCH_INDICATOR_PM); } } snprintf(buf, sizeof(buf), "%02d00", hour); watch_display_text_with_fallback(WATCH_POSITION_BOTTOM, buf, buf); } static void _settings_sleep_time_advance(void *context) { hydration_state_t *state = (hydration_state_t *) context; state->sleep_hour += 1; if (state->sleep_hour >= 24) { state->sleep_hour = 0; } } static void _settings_alert_interval_display(void *context, uint8_t subsecond) { char buf[10]; hydration_state_t *state = (hydration_state_t *) context; _settings_title_display(state, "INTER", "IN"); if (_settings_blink(subsecond)) return; /* No time setting so clear colon and indicators */ watch_clear_colon(); watch_clear_indicator(WATCH_INDICATOR_24H); watch_clear_indicator(WATCH_INDICATOR_PM); if (state->alert_interval == 0) { snprintf(buf, sizeof(buf), " off "); } else { snprintf(buf, sizeof(buf), " %2dh ", state->alert_interval); } watch_display_text_with_fallback(WATCH_POSITION_BOTTOM, buf, buf); } static void _settings_alert_interval_advance(void *context) { hydration_state_t *state = (hydration_state_t *) context; state->alert_interval += 1; if (state->alert_interval > 6) { state->alert_interval = 0; } } static void _hydration_start_sleep_animation(void) { if (!watch_sleep_animation_is_running()) { watch_start_sleep_animation(500); watch_start_indicator_blink_if_possible(WATCH_INDICATOR_COLON, 500); } } static void _hydration_stop_sleep_animation(void) { if (watch_sleep_animation_is_running()) { watch_stop_sleep_animation(); watch_stop_blink(); } } /* Play beep sound */ static inline void _beep() { if (!movement_button_should_sound()) return; watch_buzzer_play_note(BUZZER_NOTE_C7, 50); } /* Calculate expected intake */ static uint16_t _get_expected_intake(hydration_state_t *state, uint8_t hours_since_wake) { uint8_t day_hours = (state->sleep_hour + 24 - state->wake_hour) % 24; uint16_t expected_intake = (state->water_goal * hours_since_wake) / day_hours; return expected_intake; } static void _log_intake(hydration_state_t *state, uint32_t ts) { if (state->water_intake == 0) return; state->log_head = (state->log_head + HYDRATION_LOG_ENTRIES - 1) % HYDRATION_LOG_ENTRIES; hydration_log_t *log = &state->log[state->log_head]; /* Log entry (intake / 100) and date (ts / 86400) */ log->water_intake = state->water_intake / 100; log->date = ts / 86400; } static void _tracking_display(hydration_state_t *state) { char buf[4]; watch_display_text_with_fallback(WATCH_POSITION_TOP_LEFT, "HYDRA", "Hy"); if (state->alert_enabled) { watch_set_indicator(WATCH_INDICATOR_SIGNAL); } else { watch_clear_indicator(WATCH_INDICATOR_SIGNAL); } if (!state->display_deviation) { /* Display current intake in bottom */ _display_water_ml(state->water_intake); /* Display percentage in top right */ uint8_t percent = (state->water_intake * 10) / state->water_goal; snprintf(buf, sizeof(buf), "%2d", percent); watch_display_text_with_fallback(WATCH_POSITION_TOP_RIGHT, buf, buf); } else { watch_date_time_t now = movement_get_local_date_time(); uint16_t hours_since_wake = (now.unit.hour + 24 - state->wake_hour) % 24; uint16_t expected_intake = _get_expected_intake(state, hours_since_wake); int16_t deviation = state->water_intake - expected_intake; /* Display absolute deviation from expected intake */ _display_water_ml(abs(deviation)); /* Display sign in top right */ snprintf(buf, sizeof(buf), "%s", deviation >= 0 ? " +" : " -"); watch_display_text_with_fallback(WATCH_POSITION_TOP_RIGHT, buf, buf); } } #define _log_next_entry(state) _log_find_entry(state, +1) #define _log_prev_entry(state) _log_find_entry(state, -1) static uint8_t _log_find_entry(hydration_state_t *state, int direction) { uint8_t start = state->log_index; uint8_t step = (direction > 0) ? 1 : (HYDRATION_LOG_ENTRIES - 1); uint8_t idx = (start + step) % HYDRATION_LOG_ENTRIES; while (idx != start) { if (state->log[idx].date != 0) return idx; idx = (idx + step) % HYDRATION_LOG_ENTRIES; } // If no other non-empty entries found, just return current return start; } static void _log_display(hydration_state_t *state) { char buf[10]; int16_t deviation; watch_display_text_with_fallback(WATCH_POSITION_TOP_LEFT, "LOG", "LG"); hydration_log_t *log = &state->log[state->log_index]; if (log->date == 0) { watch_display_text_with_fallback(WATCH_POSITION_BOTTOM, "no dat", "no dat"); return; } /* Calculate position in ring buffer (1 = most recent) */ uint8_t distance = (state->log_index - state->log_head + HYDRATION_LOG_ENTRIES) % HYDRATION_LOG_ENTRIES; uint8_t index = distance + 1; switch (state->log_type) { case HYDRATION_LOG_INTAKE: snprintf(buf, sizeof(buf), "%2d", index); watch_display_text_with_fallback(WATCH_POSITION_TOP_RIGHT, buf, buf); _display_water_ml(log->water_intake * 100); break; case HYDRATION_LOG_DEVIATION: deviation = log->water_intake * 100 - state->water_goal; snprintf(buf, sizeof(buf), "%s", deviation >= 0 ? " +" : " -"); watch_display_text_with_fallback(WATCH_POSITION_TOP_RIGHT, buf, buf); _display_water_ml(abs(deviation)); break; case HYDRATION_LOG_DATE: snprintf(buf, sizeof(buf), "%2d", index); watch_display_text_with_fallback(WATCH_POSITION_TOP_RIGHT, buf, buf); watch_date_time_t now = watch_utility_date_time_from_unix_time(log->date * 86400, 0); snprintf(buf, sizeof(buf), "%02d%02d%02d", now.unit.day, now.unit.month, now.unit.year + 20); watch_display_text_with_fallback(WATCH_POSITION_BOTTOM, buf, buf); break; } } static void _switch_to_tracking(hydration_state_t *state) { movement_request_tick_frequency(DISPLAY_FREQUENCY); state->page = PAGE_HYDRATION_TRACKING; /* No colon or indicators in tracking mode */ watch_clear_colon(); watch_clear_indicator(WATCH_INDICATOR_24H); watch_clear_indicator(WATCH_INDICATOR_PM); /* Display tracking data */ _tracking_display(state); } static void _switch_to_settings(hydration_state_t *state) { movement_request_tick_frequency(4); state->page = PAGE_HYDRATION_SETTINGS; state->settings_page = HYDRATION_SETTING_WATER_GLASS; /* Display current settings */ state->settings[state->settings_page].display(state, 0); } static void _switch_to_log(hydration_state_t *state) { movement_request_tick_frequency(DISPLAY_FREQUENCY); state->page = PAGE_HYDRATION_LOG; /* Set index to entry before tail */ state->log_index = state->log_head; state->log_type = HYDRATION_LOG_INTAKE; /* Display log data */ _log_display(state); } static void _check_hydration_alert(hydration_state_t *state) { watch_date_time_t now = movement_get_local_date_time(); /* Check if time is between sleep and wake */ uint8_t hour = now.unit.hour; if (state->sleep_hour == state->wake_hour) { /* Disable alerts completely */ return; } else if (state->sleep_hour < state->wake_hour) { /* Sleep time not over midnight */ if (hour > state->sleep_hour && hour <= state->wake_hour) { return; } } else { /* Sleep time crosses midnight */ if (hour > state->sleep_hour || hour <= state->wake_hour) { return; } } /* Check for alert at sleep time */ bool due = (hour == state->sleep_hour); /* Check for alert at interval */ uint8_t hours_since_wake = (hour + 24 - state->wake_hour) % 24; due |= (state->alert_interval > 0 && hours_since_wake % state->alert_interval == 0); if (!due) return; uint16_t expected_intake = _get_expected_intake(state, hours_since_wake); if (state->water_intake < expected_intake) { /* This runs from the background task, regardless of which face is * currently on screen, so request the wake here rather than relying * on this face's own EVENT_LOW_ENERGY_UPDATE handler to catch it. */ movement_request_wake(); movement_move_to_face(state->face_index); movement_play_alarm(); } } static bool _tracking_loop(movement_event_t event, void *context) { hydration_state_t *state = (hydration_state_t *) context; switch (event.event_type) { case EVENT_ACTIVATE: watch_clear_colon(); _tracking_display(state); break; case EVENT_TICK: if (state->display_deviation > 0) { state->display_deviation--; } _tracking_display(state); break; case EVENT_ALARM_BUTTON_UP: /* Prevent overflow of water intake */ if (state->water_intake + state->water_glass > MAX_WATER_INTAKE) { state->water_intake = MAX_WATER_INTAKE; } else { state->water_intake += state->water_glass; } _tracking_display(state); _beep(); break; case EVENT_LIGHT_BUTTON_UP: /* Prevent underflow of water intake */ if (state->water_intake - state->water_glass < 0) { state->water_intake = 0; } else { state->water_intake -= state->water_glass; } _tracking_display(state); _beep(); break; case EVENT_LIGHT_BUTTON_DOWN: /* Do nothing. No light */ break; case EVENT_LIGHT_LONG_PRESS: _switch_to_settings(state); _beep(); break; case EVENT_ALARM_LONG_PRESS: /* Display intake deviation for 2 seconds */ state->display_deviation = 2; _tracking_display(state); break; case EVENT_ALARM_REALLY_LONG_PRESS: state->display_deviation = 0; _switch_to_log(state); _beep(); break; case EVENT_LOW_ENERGY_UPDATE: _hydration_start_sleep_animation(); break; case EVENT_BACKGROUND_TASK: _check_hydration_alert(state); break; case EVENT_TIMEOUT: movement_move_to_face(0); break; default: movement_default_loop_handler(event); break; } return true; } static bool _log_loop(movement_event_t event, void *context) { hydration_state_t *state = (hydration_state_t *) context; switch (event.event_type) { case EVENT_ACTIVATE: case EVENT_TICK: _log_display(state); break; case EVENT_ALARM_BUTTON_UP: state->log_index = _log_next_entry(state); _log_display(state); break; case EVENT_LIGHT_BUTTON_UP: state->log_type = (state->log_type + 1) % HYDRATION_LOG_TYPES; _log_display(state); _beep(); break; case EVENT_LIGHT_BUTTON_DOWN: /* Do nothing. No light */ break; case EVENT_MODE_BUTTON_UP: _switch_to_tracking(state); _beep(); break; case EVENT_BACKGROUND_TASK: _check_hydration_alert(state); break; case EVENT_TIMEOUT: movement_move_to_face(0); break; default: movement_default_loop_handler(event); break; } return true; } static bool _settings_loop(movement_event_t event, void *context) { hydration_state_t *state = (hydration_state_t *) context; switch (event.event_type) { case EVENT_ACTIVATE: case EVENT_TICK: state->settings[state->settings_page].display(context, event.subsecond); break; case EVENT_LIGHT_BUTTON_UP: state->settings_page = (state->settings_page + 1) % NUM_SETTINGS; state->settings[state->settings_page].display(context, event.subsecond); break; case EVENT_MODE_BUTTON_UP: _switch_to_tracking(state); _beep(); break; case EVENT_LIGHT_BUTTON_DOWN: /* Do nothing. No light */ break; case EVENT_ALARM_BUTTON_UP: /* Advance current settings */ state->settings[state->settings_page].advance(context); state->settings[state->settings_page].display(context, event.subsecond); break; case EVENT_ALARM_LONG_PRESS: /* Reset to default value */ switch (state->settings_page) { case HYDRATION_SETTING_WATER_GLASS: state->water_glass = DEFAULT_WATER_GLASS_ML; break; case HYDRATION_SETTING_WATER_GOAL: state->water_goal = DEFAULT_WATER_GOAL_ML; break; case HYDRATION_SETTING_WAKE_TIME: state->wake_hour = DEFAULT_WAKE_HOUR; break; case HYDRATION_SETTING_SLEEP_TIME: state->sleep_hour = DEFAULT_SLEEP_HOUR; break; case HYDRATION_SETTING_ALERT_INTERVAL: state->alert_interval = DEFAULT_ALERT_INTERVAL; break; } state->settings[state->settings_page].display(context, event.subsecond); break; case EVENT_LIGHT_LONG_PRESS: state->alert_enabled = !state->alert_enabled; state->settings[state->settings_page].display(context, event.subsecond); _beep(); break; case EVENT_BACKGROUND_TASK: _check_hydration_alert(state); break; case EVENT_TIMEOUT: movement_move_to_face(0); break; default: movement_default_loop_handler(event); break; } return true; } void hydration_face_setup(uint8_t watch_face_index, void **context_ptr) { (void) watch_face_index; if (*context_ptr != NULL) return; /* Skip setup if context available */ *context_ptr = malloc(sizeof(hydration_state_t)); memset(*context_ptr, 0, sizeof(hydration_state_t)); hydration_state_t *state = (hydration_state_t *) * context_ptr; /* Default setup */ state->water_glass = DEFAULT_WATER_GLASS_ML; state->water_goal = DEFAULT_WATER_GOAL_ML; state->wake_hour = DEFAULT_WAKE_HOUR; state->sleep_hour = DEFAULT_SLEEP_HOUR; state->alert_interval = DEFAULT_ALERT_INTERVAL; /* Initialize settings */ uint8_t settings_page = 0; state->settings = malloc(NUM_SETTINGS * sizeof(hydration_settings_t)); state->settings[settings_page].display = _settings_water_glass_display; state->settings[settings_page].advance = _settings_water_glass_advance; settings_page++; state->settings[settings_page].display = _settings_water_goal_display; state->settings[settings_page].advance = _settings_water_goal_advance; settings_page++; state->settings[settings_page].display = _settings_wake_time_display; state->settings[settings_page].advance = _settings_wake_time_advance; settings_page++; state->settings[settings_page].display = _settings_sleep_time_display; state->settings[settings_page].advance = _settings_sleep_time_advance; settings_page++; state->settings[settings_page].display = _settings_alert_interval_display; state->settings[settings_page].advance = _settings_alert_interval_advance; settings_page++; /* Store face index for background tasks */ state->face_index = watch_face_index; } void hydration_face_activate(void *context) { hydration_state_t *state = (hydration_state_t *) context; _hydration_stop_sleep_animation(); /* Switch to tracking page. */ _switch_to_tracking(state); } bool hydration_face_loop(movement_event_t event, void *context) { hydration_state_t *state = (hydration_state_t *) context; switch (state->page) { default: case PAGE_HYDRATION_TRACKING: return _tracking_loop(event, context); case PAGE_HYDRATION_SETTINGS: return _settings_loop(event, context); case PAGE_HYDRATION_LOG: return _log_loop(event, context); } } void hydration_face_resign(void *context) { (void) context; } movement_watch_face_advisory_t hydration_face_advise(void *context) { hydration_state_t *state = (hydration_state_t *) context; movement_watch_face_advisory_t retval = { 0 }; watch_date_time_t now = movement_get_local_date_time(); /* Daily reset at wake time and log entry */ if (now.unit.hour == state->wake_hour && now.unit.minute == 0) { uint32_t ts = watch_utility_date_time_to_unix_time(now, 0); /* Correct date if midnight between sleep and wake time */ if (state->wake_hour < state->sleep_hour) ts -= 24 * 60 * 60; _log_intake(state, ts); /* Reset water intake */ state->water_intake = 0; } /* Check for alert at every hour */ if (state->alert_enabled && now.unit.minute == 0) { retval.wants_background_task = true; } return retval; }