From 33313818d46ce2448ff3dfcc7903909d0d57c3ae Mon Sep 17 00:00:00 2001 From: Mitchell Date: Sat, 25 Apr 2026 00:00:20 -0500 Subject: [PATCH] feat: heart rate indicator - Add new setting for watches that support the heart rate monitor to show it where the bluetooth icon used to be. In this case, show the bluetooth icon next to the battery indicator. --- package.json | 4 +- src/c/battery.c | 21 ++++++-- src/c/battery.h | 4 ++ src/c/bluetooth.c | 25 ++++++++- src/c/font.c | 1 + src/c/font.h | 1 + src/c/health.c | 131 ++++++++++++++++++++++++++++++++++++++++------ src/c/health.h | 4 ++ src/c/main.c | 18 +++++-- 9 files changed, 181 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 4ee6d50..c3ec7cb 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "type": "font", "name": "FONT_ICONS_28", "file": "fonts/Lilex/LilexNerdFontMono-Regular.ttf", - "characterRegex": "[ ]" + "characterRegex": "[ ]" }, { "type": "font", @@ -83,7 +83,7 @@ "targetPlatforms": [ "emery" ], - "characterRegex": "[ ]" + "characterRegex": "[ ]" }, { "type": "font", diff --git a/src/c/battery.c b/src/c/battery.c index f5fcc3c..0306dff 100644 --- a/src/c/battery.c +++ b/src/c/battery.c @@ -5,6 +5,8 @@ static TextLayer *s_battery_layer_text; static TextLayer *s_battery_layer_icon; +int BATTERY_ROW_MAX_WIDTH = 0; + /** * Update battery icon and text. */ @@ -34,25 +36,36 @@ void battery_init() { battery_state_service_subscribe(battery_update_handler); } +// WARNING: Must be called before bluetooth loads. void battery_load(Window *window, int row_height) { Layer *window_layer = window_get_root_layer(window); GRect bounds = layer_get_bounds(window_layer); // Battery icon. - s_battery_layer_icon = font_render_icon_small(window_layer, ICON_BATTERY_50, PADDING_X, 0, true, false); + s_battery_layer_icon = font_render_icon_small(window_layer, ICON_BATTERY_100, PADDING_X, 0, true, false); text_layer_set_text_color(s_battery_layer_icon, THEME.text_color); // Battery percentage. GRect battery_icon_bounds = layer_get_bounds(text_layer_get_layer(s_battery_layer_icon)); - s_battery_layer_text = - text_layer_create(GRect(battery_icon_bounds.size.w - battery_icon_bounds.size.w, 0, - bounds.size.w - battery_icon_bounds.size.w - PADDING_X - 2, row_height)); + s_battery_layer_text = text_layer_create( + GRect(battery_icon_bounds.size.w - battery_icon_bounds.size.w, 0, + bounds.size.w - battery_icon_bounds.size.w - PADDING_X - BATTERY_TEXT_RIGHT_INSET, row_height)); text_layer_set_text_alignment(s_battery_layer_text, GTextAlignmentRight); text_layer_set_font(s_battery_layer_text, s_font_primary_small); text_layer_set_text_color(s_battery_layer_text, THEME.text_color); text_layer_set_background_color(s_battery_layer_text, GColorClear); layer_add_child(window_layer, text_layer_get_layer(s_battery_layer_text)); + + // Set it to 100 so we can calculate the max width. + text_layer_set_text(s_battery_layer_text, "100"); + + // Before we call the update handler, calculate the max width of the + // battery text + icon. + GSize battery_icon_size = text_layer_get_content_size(s_battery_layer_icon); + GSize battery_text_size = text_layer_get_content_size(s_battery_layer_text); + BATTERY_ROW_MAX_WIDTH = battery_icon_size.w + battery_text_size.w; + battery_update_handler(battery_state_service_peek()); } diff --git a/src/c/battery.h b/src/c/battery.h index 0bc16b4..e67566f 100644 --- a/src/c/battery.h +++ b/src/c/battery.h @@ -2,6 +2,10 @@ #include +#define BATTERY_TEXT_RIGHT_INSET 2 + +extern int BATTERY_ROW_MAX_WIDTH; + void battery_init(); void battery_load(Window *window, int row_height); void battery_unload(); diff --git a/src/c/bluetooth.c b/src/c/bluetooth.c index f699015..d8ce4e3 100644 --- a/src/c/bluetooth.c +++ b/src/c/bluetooth.c @@ -1,4 +1,6 @@ #include "bluetooth.h" +#include "battery.h" +#include "health.h" #include "log.h" #include "pebble.h" #include "settings.h" @@ -42,7 +44,8 @@ void bluetooth_deinit(void) { connection_service_unsubscribe(); } -void bluetooth_load(Window *window) { +static void bluetooth_load_middle_right(Window *window) { + Layer *window_layer = window_get_root_layer(window); GRect bounds = layer_get_bounds(window_layer); @@ -55,6 +58,26 @@ void bluetooth_load(Window *window) { s_bluetooth_layer_icon = font_render_icon_xsmall(window_layer, ICON_BLUETOOTH_CONNECTED, PADDING_X, icon_y, true, false); text_layer_set_text_color(s_bluetooth_layer_icon, THEME.text_color); +} + +static void bluetooth_load_top(Window *window) { + Layer *window_layer = window_get_root_layer(window); + + int right_offset = PADDING_X + BATTERY_TEXT_RIGHT_INSET + BATTERY_ROW_MAX_WIDTH + 2; + int top_offset = 8; + + s_bluetooth_layer_icon = + font_render_icon_xsmall(window_layer, ICON_BLUETOOTH_CONNECTED, right_offset, top_offset, true, false); + text_layer_set_text_color(s_bluetooth_layer_icon, THEME.text_color); +} + +// WARNING: Must be called after battery loads. +void bluetooth_load(Window *window) { +#ifdef HEART_RATE_SUPPORTED + bluetooth_load_top(window); +#else + bluetooth_load_middle_right(window); +#endif // Refresh state in case the initial peek happened before the service was ready. bluetooth_refresh_connected_state(); diff --git a/src/c/font.c b/src/c/font.c index 6ce51fa..3632811 100644 --- a/src/c/font.c +++ b/src/c/font.c @@ -17,6 +17,7 @@ const char *ICON_BATTERY_50 = ""; const char *ICON_BATTERY_75 = ""; const char *ICON_BATTERY_100 = ""; const char *ICON_STEPS = ""; +const char *ICON_HEART_RATE = ""; const char *ICON_UTC = ""; const char *ICON_BLUETOOTH_CONNECTED = "󰂯"; const char *ICON_SUNRISE = ""; diff --git a/src/c/font.h b/src/c/font.h index 44cf4f1..855dd92 100644 --- a/src/c/font.h +++ b/src/c/font.h @@ -19,6 +19,7 @@ extern const char *ICON_BATTERY_50; extern const char *ICON_BATTERY_75; extern const char *ICON_BATTERY_100; extern const char *ICON_STEPS; +extern const char *ICON_HEART_RATE; extern const char *ICON_UTC; extern const char *ICON_BLUETOOTH_CONNECTED; extern const char *ICON_SUNRISE; diff --git a/src/c/health.c b/src/c/health.c index 0b7d073..012b53e 100644 --- a/src/c/health.c +++ b/src/c/health.c @@ -1,11 +1,28 @@ #include "health.h" #include "common.h" #include "font.h" +#include "settings.h" +#include "time.h" -TextLayer *s_steps_layer_text; -TextLayer *s_steps_layer_icon; +static TextLayer *s_steps_layer_text; +static TextLayer *s_steps_layer_icon; + +#if defined(HEART_RATE_SUPPORTED) +static TextLayer *s_heart_rate_layer_text; +static TextLayer *s_heart_rate_layer_icon; + +#if defined(PBL_PLATFORM_EMERY) +static const int HEART_RATE_Y_OFFSET = 9; +#else +static const int HEART_RATE_Y_OFFSET = 0; +#endif +#endif + +static void health_update_steps(void) { + if (!s_steps_layer_text) { + return; + } -static void health_update(void) { static char steps_buffer[16]; HealthServiceAccessibilityMask access = @@ -20,9 +37,43 @@ static void health_update(void) { text_layer_set_text(s_steps_layer_text, steps_buffer); } +static void health_update_heart_rate(void) { +#if defined(HEART_RATE_SUPPORTED) + if (!s_heart_rate_layer_text) { + return; + } + + static char heart_rate_buffer[16]; + + HealthServiceAccessibilityMask access = health_service_metric_aggregate_averaged_accessible( + HealthMetricHeartRateBPM, time(NULL), time(NULL), HealthAggregationAvg, HealthServiceTimeScopeOnce); + if (access & HealthServiceAccessibilityMaskAvailable) { + HealthValue heart_rate = health_service_peek_current_value(HealthMetricHeartRateBPM); + if (heart_rate > 0) { + snprintf(heart_rate_buffer, sizeof(heart_rate_buffer), "%ld", (long)heart_rate); + } else { + snprintf(heart_rate_buffer, sizeof(heart_rate_buffer), "--"); + } + } else { + snprintf(heart_rate_buffer, sizeof(heart_rate_buffer), "--"); + } + + text_layer_set_text(s_heart_rate_layer_text, heart_rate_buffer); +#endif +} + +static void health_update(void) { + health_update_steps(); + health_update_heart_rate(); +} + static void health_handler(HealthEventType event, void *context) { if (event == HealthEventMovementUpdate || event == HealthEventSignificantUpdate) { - health_update(); + health_update_steps(); + } + + if (event == HealthEventHeartRateUpdate || event == HealthEventSignificantUpdate) { + health_update_heart_rate(); } } @@ -35,23 +86,69 @@ void health_load(Window *window, int row_height) { Layer *window_layer = window_get_root_layer(window); GRect bounds = layer_get_bounds(window_layer); - int steps_y = row_height + 2; - s_steps_layer_icon = font_render_icon_small(window_layer, ICON_STEPS, PADDING_X, steps_y, true, false); - text_layer_set_text_color(s_steps_layer_icon, THEME.text_color); - GRect steps_icon_bounds = layer_get_bounds(text_layer_get_layer(s_steps_layer_icon)); + // --- Steps --- + + if (app_settings.show_steps) { + int steps_y = row_height + 2; + s_steps_layer_icon = font_render_icon_small(window_layer, ICON_STEPS, PADDING_X, steps_y, true, false); + text_layer_set_text_color(s_steps_layer_icon, THEME.text_color); + GRect steps_icon_bounds = layer_get_bounds(text_layer_get_layer(s_steps_layer_icon)); + + s_steps_layer_text = + text_layer_create(GRect(0, steps_y, bounds.size.w - steps_icon_bounds.size.w - PADDING_X - 2, row_height)); + text_layer_set_text_alignment(s_steps_layer_text, GTextAlignmentRight); + text_layer_set_font(s_steps_layer_text, s_font_primary_small); + text_layer_set_text_color(s_steps_layer_text, THEME.text_color); + text_layer_set_background_color(s_steps_layer_text, GColorClear); + layer_add_child(window_layer, text_layer_get_layer(s_steps_layer_text)); + } + + // --- Heart Rate --- + +#if defined(HEART_RATE_SUPPORTED) +#if defined(PBL_PLATFORM_EMERY) + int heart_rate_y = (bounds.size.h / 2) - (TIME_CONTAINER_HEIGHT / 2) - 20 - HEART_RATE_Y_OFFSET; +#else + int heart_rate_y = (bounds.size.h / 2) - (TIME_CONTAINER_HEIGHT / 2) - 14 - HEART_RATE_Y_OFFSET; +#endif + s_heart_rate_layer_icon = + font_render_icon_small(window_layer, ICON_HEART_RATE, PADDING_X, heart_rate_y, true, false); + text_layer_set_text_color(s_heart_rate_layer_icon, THEME.text_color); + GRect heart_rate_icon_bounds = layer_get_bounds(text_layer_get_layer(s_heart_rate_layer_icon)); - s_steps_layer_text = text_layer_create(GRect(steps_icon_bounds.size.w - steps_icon_bounds.size.w, steps_y, - bounds.size.w - steps_icon_bounds.size.w - PADDING_X - 2, row_height)); - text_layer_set_text_alignment(s_steps_layer_text, GTextAlignmentRight); - text_layer_set_font(s_steps_layer_text, s_font_primary_small); - text_layer_set_text_color(s_steps_layer_text, THEME.text_color); - text_layer_set_background_color(s_steps_layer_text, GColorClear); - layer_add_child(window_layer, text_layer_get_layer(s_steps_layer_text)); + s_heart_rate_layer_text = text_layer_create( + GRect(0, heart_rate_y, bounds.size.w - heart_rate_icon_bounds.size.w - PADDING_X - 2, row_height)); + text_layer_set_text_alignment(s_heart_rate_layer_text, GTextAlignmentRight); + text_layer_set_font(s_heart_rate_layer_text, s_font_primary_small); + text_layer_set_text_color(s_heart_rate_layer_text, THEME.text_color); + text_layer_set_background_color(s_heart_rate_layer_text, GColorClear); + layer_add_child(window_layer, text_layer_get_layer(s_heart_rate_layer_text)); +#endif + + health_update(); } void health_unload() { - text_layer_destroy(s_steps_layer_icon); - text_layer_destroy(s_steps_layer_text); + if (s_steps_layer_icon) { + text_layer_destroy(s_steps_layer_icon); + s_steps_layer_icon = NULL; + } + + if (s_steps_layer_text) { + text_layer_destroy(s_steps_layer_text); + s_steps_layer_text = NULL; + } +#if defined(HEART_RATE_SUPPORTED) + if (s_heart_rate_layer_icon) { + text_layer_destroy(s_heart_rate_layer_icon); + s_heart_rate_layer_icon = NULL; + } + + if (s_heart_rate_layer_text) { + text_layer_destroy(s_heart_rate_layer_text); + s_heart_rate_layer_text = NULL; + } +#endif } void health_deinit() { diff --git a/src/c/health.h b/src/c/health.h index ba97d9e..5219c04 100644 --- a/src/c/health.h +++ b/src/c/health.h @@ -2,6 +2,10 @@ #include +#if defined(PBL_PLATFORM_DIORITE) || defined(PBL_PLATFORM_EMERY) +#define HEART_RATE_SUPPORTED +#endif + void health_init(); void health_load(Window *window, int row_height); void health_unload(); diff --git a/src/c/main.c b/src/c/main.c index 15336b7..de88afd 100644 --- a/src/c/main.c +++ b/src/c/main.c @@ -16,6 +16,16 @@ static int s_last_vibrate_hour = -1; +#if defined(PBL_HEALTH) +static bool health_should_run(void) { +#if defined(HEART_RATE_SUPPORTED) + return true; +#else + return app_settings.show_steps; +#endif +} +#endif + static void tick_handler(struct tm *tick_time, TimeUnits units_changed) { LOG_DEBUG("tick handler..."); time_update(); @@ -36,7 +46,7 @@ void load_top_right(Window *window) { #endif battery_load(window, row_height); #if defined(PBL_HEALTH) - if (app_settings.show_steps) { + if (health_should_run()) { health_load(window, row_height); } #endif @@ -59,7 +69,7 @@ static void window_unload(Window *window) { bluetooth_unload(); battery_unload(); #if defined(PBL_HEALTH) - if (app_settings.show_steps) { + if (health_should_run()) { health_unload(); } #endif @@ -80,7 +90,7 @@ static void init(void) { battery_init(); bluetooth_init(); #if defined(PBL_HEALTH) - if (app_settings.show_steps) { + if (health_should_run()) { health_init(); } #endif @@ -92,7 +102,7 @@ static void deinit(void) { bluetooth_deinit(); battery_deinit(); #if defined(PBL_HEALTH) - if (app_settings.show_steps) { + if (health_should_run()) { health_deinit(); } #endif