Skip to content

Commit 0f94344

Browse files
aveaoclaude
andcommitted
health: split typical pill into bin-time + daily-total on wide displays
Redesign the activity-summary "typical" pill so it surfaces both the bin-aware "by now" step count and the full-day weekday historical average, side by side, on wide (>= 200px) displays. The header stays "TYPICAL <day>"; below it a two-column row shows each number on top with its label beneath (bin time | TOTAL) in black, separated by a 1px black vertical divider. Narrower displays (incl. BW) stay two-line: TYPICAL <day> over the daily total alone, since they lack room for the split. Renderer (ui.c): split the single renderer into two functions sharing a prv_render_typical_pill helper (background + "TYPICAL <day>" header): - health_ui_render_typical_text_box draws one value line below the header (35/36px pill). - health_ui_render_split_typical_text_box draws the two-column split (53px pill). It takes a value and label per column from the caller, so it stays generic rather than steps-specific. - Push the pill down by a third of HEALTH_Y_OFFSET (and the current step count by a sixth) so spacing stays balanced on taller displays; zero on legacy-sized displays where the offset is 0. - On round, narrow the column band so the two halves sit closer to center instead of marooned at the display edges. Activity card: prv_render_typical_steps now formats the values and labels (incl. i18n_get("TOTAL") and the bin time) and routes by display width with #if DISP_COLS >= 200 -- wide gets the split, narrow shows the daily total alone. Missing values fall back to an em-dash. Data layer: add health_data_steps_get_current_average_minute, a health-app-internal accessor returning the minute-of-day of the latest completed step-average bin (cached by the preceding health_data_steps_get_current_average call). Sleep card: call the single-value renderer. Test fixture: pin RTC to Saturday 16:19:35 UTC with 24h clock so the rendered "16:15" bin time is deterministic; seed step_average_last_updated_time per case. Regenerate activity, sleep, and card-view goldens on obelix/gabbro. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Ave Özkal <git@ave.zone>
1 parent d2944fc commit 0f94344

51 files changed

Lines changed: 176 additions & 15 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/fw/apps/system/health/activity_summary_card.c

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414
#include "board/display.h"
1515
#include "kernel/pbl_malloc.h"
1616
#include "resource/resource_ids.auto.h"
17+
#include "pbl/services/clock.h"
1718
#include "pbl/services/i18n/i18n.h"
1819
#include "system/logging.h"
1920
#include "util/size.h"
2021
#include "util/string.h"
22+
#include "util/time/time.h"
2123

2224
// Compile-time display offset calculations
2325
#define HEALTH_X_OFFSET ((DISP_COLS - LEGACY_2X_DISP_COLS) / 2)
@@ -36,6 +38,7 @@ typedef struct HealthActivitySummaryCardData {
3638
KinoReel *icon;
3739
int32_t current_steps;
3840
int32_t typical_steps;
41+
int32_t typical_steps_bin_minute;
3942
int32_t daily_average_steps;
4043
} HealthActivitySummaryCardData;
4144

@@ -109,31 +112,63 @@ static void prv_render_current_steps(GContext *ctx, Layer *base_layer) {
109112
snprintf(buffer, sizeof(buffer), EM_DASH);
110113
}
111114

112-
const int y = PBL_IF_RECT_ELSE(PBL_IF_BW_ELSE(85, 83), 88) + HEALTH_Y_OFFSET;
115+
// Mirror the pill's downshift at half the offset so the step count and
116+
// pill stay visually balanced. Zero on legacy-sized displays where
117+
// HEALTH_Y_OFFSET itself is 0.
118+
const int y = PBL_IF_RECT_ELSE(PBL_IF_BW_ELSE(85, 83), 88) + HEALTH_Y_OFFSET
119+
+ HEALTH_Y_OFFSET / 6;
113120
graphics_context_set_text_color(ctx, CURRENT_TEXT_COLOR);
114121
graphics_draw_text(ctx, buffer, font,
115122
GRect(0, y, base_layer->bounds.size.w, 40),
116123
GTextOverflowModeFill, GTextAlignmentCenter, NULL);
117124
}
118125

126+
// Formats a step count into buffer, or an em-dash when the count is missing.
127+
static void prv_format_step_count(char *buffer, size_t size, int32_t steps) {
128+
if (steps > 0) {
129+
snprintf(buffer, size, "%"PRId32, steps);
130+
} else {
131+
snprintf(buffer, size, EM_DASH);
132+
}
133+
}
134+
119135
static void prv_render_typical_steps(GContext *ctx, Layer *base_layer) {
120136
HealthActivitySummaryCardData *data = layer_get_data(base_layer);
121137

138+
char daily_buffer[12];
139+
prv_format_step_count(daily_buffer, sizeof(daily_buffer), data->daily_average_steps);
140+
141+
#if DISP_COLS >= 200
142+
// Wide displays show a two-column split: the bin-aware count under its bin
143+
// time on the left, the full-day total under a "TOTAL" label on the right.
144+
// Missing values fall back to an em-dash.
122145
char steps_buffer[12];
123-
if (data->typical_steps) {
124-
snprintf(steps_buffer, sizeof(steps_buffer), "%"PRId32, data->typical_steps);
146+
prv_format_step_count(steps_buffer, sizeof(steps_buffer), data->typical_steps);
147+
148+
char bin_time[12];
149+
if (data->typical_steps > 0) {
150+
clock_format_time(bin_time, sizeof(bin_time),
151+
data->typical_steps_bin_minute / MINUTES_PER_HOUR,
152+
data->typical_steps_bin_minute % MINUTES_PER_HOUR,
153+
false /* add_space */);
125154
} else {
126-
snprintf(steps_buffer, sizeof(steps_buffer), EM_DASH);
155+
snprintf(bin_time, sizeof(bin_time), EM_DASH);
127156
}
128157

129-
health_ui_render_typical_text_box(ctx, base_layer, steps_buffer);
158+
health_ui_render_split_typical_text_box(ctx, base_layer, steps_buffer, bin_time,
159+
daily_buffer, i18n_get("TOTAL", base_layer));
160+
#else
161+
// Narrow displays show just the daily total (em-dash when missing).
162+
health_ui_render_typical_text_box(ctx, base_layer, daily_buffer);
163+
#endif
130164
}
131165

132166
static void prv_base_layer_update_proc(Layer *base_layer, GContext *ctx) {
133167
HealthActivitySummaryCardData *data = layer_get_data(base_layer);
134168

135169
data->current_steps = health_data_current_steps_get(data->health_data);
136170
data->typical_steps = health_data_steps_get_current_average(data->health_data);
171+
data->typical_steps_bin_minute = health_data_steps_get_current_average_minute(data->health_data);
137172
data->daily_average_steps = health_data_steps_get_cur_wday_average(data->health_data);
138173

139174
prv_render_icon(ctx, base_layer);

src/fw/apps/system/health/data.c

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,10 @@ int32_t health_data_steps_get_current_average(HealthData *health_data) {
233233
return health_data->current_step_average;
234234
}
235235

236+
int32_t health_data_steps_get_current_average_minute(HealthData *health_data) {
237+
return health_data->step_average_last_updated_time;
238+
}
239+
236240
int32_t health_data_steps_get_cur_wday_average(HealthData *health_data) {
237241
return prv_health_data_get_n_average_chunks(health_data, ACTIVITY_NUM_METRIC_AVERAGES);
238242
}

src/fw/apps/system/health/data.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ int32_t health_data_current_calories_get(HealthData *health_data);
9090
//! @return The integer number of steps that should be taken by this time today
9191
int32_t health_data_steps_get_current_average(HealthData *health_data);
9292

93+
//! Get the minute-of-day of the latest completed step-average bin. Reflects the
94+
//! value cached by the most recent health_data_steps_get_current_average() call.
95+
//! @param health_data A pointer to the health data to use
96+
//! @return The minute-of-day (0..1439) of the latest completed step-average bin
97+
int32_t health_data_steps_get_current_average_minute(HealthData *health_data);
98+
9399
//! Get the step average for the current day of the week
94100
//! @param health_data A pointer to the health data to use
95101
//! @return An integer value for the number of steps that are typically taken on this week day

src/fw/apps/system/health/sleep_summary_card.c

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,11 @@ static void prv_render_icon(GContext *ctx, Layer *base_layer) {
164164
static void prv_render_current_sleep_text(GContext *ctx, Layer *base_layer) {
165165
HealthSleepSummaryCardData *data = layer_get_data(base_layer);
166166

167-
const int y = PBL_IF_RECT_ELSE(PBL_IF_BW_ELSE(85, 83), 88) + HEALTH_Y_OFFSET;
167+
// Mirror the pill's downshift at half the offset so the step count and
168+
// pill stay visually balanced. Zero on legacy-sized displays where
169+
// HEALTH_Y_OFFSET itself is 0.
170+
const int y = PBL_IF_RECT_ELSE(PBL_IF_BW_ELSE(85, 83), 88) + HEALTH_Y_OFFSET
171+
+ HEALTH_Y_OFFSET / 6;
168172
const GRect rect = GRect(0, y, base_layer->bounds.size.w, 40);
169173

170174
const int current_sleep = health_data_current_sleep_get(data->health_data);

src/fw/apps/system/health/ui.c

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33

44
#include "ui.h"
55

6+
#include <inttypes.h>
7+
68
#include "applib/pbl_std/pbl_std.h"
79
#include "board/display.h"
10+
#include "pbl/services/clock.h"
811
#include "pbl/services/i18n/i18n.h"
912
#include "util/string.h"
13+
#include "util/time/time.h"
1014

1115
// Compile-time display offset calculations
1216
#define HEALTH_X_OFFSET ((DISP_COLS - LEGACY_2X_DISP_COLS) / 2)
@@ -47,7 +51,11 @@ void health_ui_draw_text_in_box(GContext *ctx, const char *text, const GRect dra
4751
}
4852
}
4953

50-
void health_ui_render_typical_text_box(GContext *ctx, Layer *layer, const char *value_text) {
54+
// Draws the rounded pill background and the "TYPICAL <day>" header, common to
55+
// both the single-value and split layouts. Returns the pill rect with origin.y
56+
// already nudged up to compensate for the text renderer's top padding, so
57+
// callers can position their body rows relative to it.
58+
static GRect prv_render_typical_pill(GContext *ctx, Layer *layer, int pill_height) {
5159
time_t now = rtc_get_time();
5260
struct tm time_tm;
5361
localtime_r(&now, &time_tm);
@@ -58,8 +66,14 @@ void health_ui_render_typical_text_box(GContext *ctx, Layer *layer, const char *
5866
char typical_text[32];
5967
snprintf(typical_text, sizeof(typical_text), i18n_get("TYPICAL %s", layer), weekday);
6068

61-
const int y = PBL_IF_RECT_ELSE(PBL_IF_BW_ELSE(122, 120), 125) + HEALTH_Y_OFFSET;
62-
GRect rect = GRect(0, y, layer->bounds.size.w, PBL_IF_RECT_ELSE(35, 36));
69+
// Push the pill lower by a third of the display's vertical health-offset.
70+
// Scales to the slack available — zero on legacy-sized displays (asterix,
71+
// flint), a few px on taller displays (obelix, getafix). Applies to both
72+
// activity and sleep cards on those taller displays; the extra breathing
73+
// room reads better on both.
74+
const int y = PBL_IF_RECT_ELSE(PBL_IF_BW_ELSE(122, 120), 125) + HEALTH_Y_OFFSET
75+
+ HEALTH_Y_OFFSET / 3;
76+
GRect rect = GRect(0, y, layer->bounds.size.w, pill_height);
6377
#if PBL_RECT
6478
rect = grect_inset(rect, GEdgeInsets(0, HEALTH_INSET));
6579
#endif
@@ -71,17 +85,74 @@ void health_ui_render_typical_text_box(GContext *ctx, Layer *layer, const char *
7185
graphics_context_set_fill_color(ctx, bg_color);
7286
graphics_fill_round_rect(ctx, &rect, 3, GCornersAll);
7387

88+
// Compensate for the text renderer's top padding so the header glyphs sit
89+
// just inside the pill's visual top edge.
7490
rect.origin.y -= PBL_IF_RECT_ELSE(3, 2);
75-
// Restrict the rect to draw one line at a time to prevent them from wrapping into each other
76-
rect.size.h = 16;
7791

7892
graphics_context_set_text_color(ctx, text_color);
7993

80-
graphics_draw_text(ctx, typical_text, font, rect,
94+
// Row 1: TYPICAL <day>, full width.
95+
GRect header_rect = rect;
96+
header_rect.size.h = 16;
97+
graphics_draw_text(ctx, typical_text, font, header_rect,
8198
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
8299

83-
rect.origin.y += 16;
100+
return rect;
101+
}
102+
103+
void health_ui_render_typical_text_box(GContext *ctx, Layer *layer, const char *value_text) {
104+
GRect rect = prv_render_typical_pill(ctx, layer, PBL_IF_RECT_ELSE(35, 36));
84105

85-
graphics_draw_text(ctx, value_text, font, rect,
106+
const GFont font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD);
107+
108+
// Row 2: the single value line, full width below the header.
109+
GRect value_rect = rect;
110+
value_rect.size.h = 16;
111+
value_rect.origin.y += 16;
112+
graphics_draw_text(ctx, value_text, font, value_rect,
86113
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
87114
}
115+
116+
void health_ui_render_split_typical_text_box(GContext *ctx, Layer *layer,
117+
const char *left_value, const char *left_label,
118+
const char *right_value, const char *right_label) {
119+
GRect rect = prv_render_typical_pill(ctx, layer, 53);
120+
121+
const GFont font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD);
122+
const GFont small_font = fonts_get_system_font(FONT_KEY_GOTHIC_14_BOLD);
123+
124+
// On round we narrow the column band so the two halves sit closer to the
125+
// pill's center instead of marooned at the round display's edges.
126+
const int16_t col_band_inset = PBL_IF_RECT_ELSE(0, 40);
127+
const int16_t col_w = (rect.size.w - 2 * col_band_inset) / 2;
128+
129+
// Row 2: values, numbers on top.
130+
GRect val_left = GRect(rect.origin.x + col_band_inset, rect.origin.y + 18,
131+
col_w, 16);
132+
GRect val_right = val_left;
133+
val_right.origin.x += val_left.size.w;
134+
graphics_draw_text(ctx, left_value, font, val_left,
135+
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
136+
graphics_draw_text(ctx, right_value, font, val_right,
137+
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
138+
139+
// Row 3: labels below the numbers, in black.
140+
const GColor label_color = GColorBlack;
141+
GRect sub_left = val_left;
142+
sub_left.origin.y += 18;
143+
sub_left.size.h = 14;
144+
GRect sub_right = sub_left;
145+
sub_right.origin.x += sub_left.size.w;
146+
graphics_context_set_text_color(ctx, label_color);
147+
graphics_draw_text(ctx, left_label, small_font, sub_left,
148+
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
149+
graphics_draw_text(ctx, right_label, small_font, sub_right,
150+
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
151+
152+
// Vertical divider between the two columns, 1px wide black.
153+
graphics_context_set_stroke_color(ctx, label_color);
154+
graphics_context_set_stroke_width(ctx, 1);
155+
const int16_t divider_x = rect.origin.x + rect.size.w / 2;
156+
graphics_draw_line(ctx, GPoint(divider_x, val_left.origin.y + 6),
157+
GPoint(divider_x, sub_left.origin.y + sub_left.size.h));
158+
}

src/fw/apps/system/health/ui.h

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,23 @@ void health_ui_draw_text_in_box(GContext *ctx, const char *text, const GRect dra
99
const int16_t y_offset, const GFont small_font, GColor box_color,
1010
GColor text_color);
1111

12-
void health_ui_render_typical_text_box(GContext *ctx, Layer *layer, const char *value_text);
12+
//! Render the "TYPICAL <day>" pill with a single value line below the header.
13+
//! Used on narrow displays and on the sleep card.
14+
//!
15+
//! @param value_text Pre-formatted value text for the lower line (e.g. the
16+
//! daily-total step count, or the typical sleep duration).
17+
void health_ui_render_typical_text_box(GContext *ctx, Layer *layer,
18+
const char *value_text);
19+
20+
//! Render the "TYPICAL <day>" pill as a two-column split: each column shows a
21+
//! value on top and a label beneath, separated by a vertical divider. Used on
22+
//! wide (>= 200px) displays for the activity card. The caller supplies all four
23+
//! strings so the renderer stays generic (not steps-specific).
24+
//!
25+
//! @param left_value Pre-formatted value for the left column (top).
26+
//! @param left_label Label for the left column (bottom).
27+
//! @param right_value Pre-formatted value for the right column (top).
28+
//! @param right_label Label for the right column (bottom).
29+
void health_ui_render_split_typical_text_box(GContext *ctx, Layer *layer,
30+
const char *left_value, const char *left_label,
31+
const char *right_value, const char *right_label);

tests/fw/apps/system_apps/health/test_health_activity_summary_card.c

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,16 @@ GContext *graphics_context_get_current_context(void) {
1919
return &s_ctx;
2020
}
2121

22+
// Override fake_clock's WEAK default so the split layout's bin time renders
23+
// in a stable 24h format.
24+
bool clock_is_24h_style(void) {
25+
return true;
26+
}
27+
2228
void test_health_activity_summary_card__initialize(void) {
29+
// Pin RTC to 2024-01-06 16:19:35 UTC (Saturday 16:19 -> bin minute 975 = 16:15).
30+
rtc_set_time(1704557975);
31+
2332
// Setup graphics context
2433
framebuffer_init(&s_fb, &(GSize) {DISP_COLS, DISP_ROWS});
2534
framebuffer_clear(&s_fb);
@@ -79,6 +88,7 @@ void test_health_activity_summary_card__no_current_steps(void) {
7988
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
8089
10, 10, 10, 10, 10, 50}, // 1000
8190
.current_step_average = 750,
91+
.step_average_last_updated_time = 975,
8292
};
8393

8494
prv_create_card_and_render(&health_data);
@@ -99,6 +109,7 @@ void test_health_activity_summary_card__render_current_behind_typical1(void) {
99109
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
100110
10, 10, 10, 10, 10, 50}, // 1000
101111
.current_step_average = 340,
112+
.step_average_last_updated_time = 975,
102113
};
103114

104115
prv_create_card_and_render(&health_data);
@@ -119,6 +130,7 @@ void test_health_activity_summary_card__render_current_behind_typical2(void) {
119130
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
120131
10, 10, 10, 10, 10, 50}, // 1000
121132
.current_step_average = 340,
133+
.step_average_last_updated_time = 975,
122134
};
123135

124136
prv_create_card_and_render(&health_data);
@@ -139,6 +151,7 @@ void test_health_activity_summary_card__render_current_behind_typical3(void) {
139151
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
140152
10, 10, 10, 10, 10, 50}, // 1000
141153
.current_step_average = 555,
154+
.step_average_last_updated_time = 975,
142155
};
143156

144157
prv_create_card_and_render(&health_data);
@@ -159,6 +172,7 @@ void test_health_activity_summary_card__render_current_behind_typical4(void) {
159172
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
160173
10, 10, 10, 10, 10, 50}, // 1000
161174
.current_step_average = 840,
175+
.step_average_last_updated_time = 975,
162176
};
163177

164178
prv_create_card_and_render(&health_data);
@@ -179,6 +193,7 @@ void test_health_activity_summary_card__render_current_behind_typical5(void) {
179193
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
180194
10, 10, 10, 10, 10, 50}, // 1000
181195
.current_step_average = 914,
196+
.step_average_last_updated_time = 975,
182197
};
183198

184199
prv_create_card_and_render(&health_data);
@@ -199,6 +214,7 @@ void test_health_activity_summary_card__render_current_equals_typical(void) {
199214
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
200215
10, 10, 10, 10, 10, 50}, // 1000
201216
.current_step_average = 837,
217+
.step_average_last_updated_time = 975,
202218
};
203219

204220
prv_create_card_and_render(&health_data);
@@ -219,6 +235,7 @@ void test_health_activity_summary_card__render_current_above_typical1(void) {
219235
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
220236
10, 10, 10, 10, 10, 50}, // 1000
221237
.current_step_average = 170,
238+
.step_average_last_updated_time = 975,
222239
};
223240

224241
prv_create_card_and_render(&health_data);
@@ -239,6 +256,7 @@ void test_health_activity_summary_card__render_current_above_typical2(void) {
239256
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
240257
10, 10, 10, 10, 10, 50}, // 1000
241258
.current_step_average = 379,
259+
.step_average_last_updated_time = 975,
242260
};
243261

244262
prv_create_card_and_render(&health_data);
@@ -259,6 +277,7 @@ void test_health_activity_summary_card__render_current_above_typical3(void) {
259277
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
260278
10, 10, 10, 10, 10, 50}, // 1000
261279
.current_step_average = 480,
280+
.step_average_last_updated_time = 975,
262281
};
263282

264283
prv_create_card_and_render(&health_data);
@@ -279,6 +298,7 @@ void test_health_activity_summary_card__render_current_above_typical4(void) {
279298
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
280299
10, 10, 10, 10, 10, 50}, // 1000
281300
.current_step_average = 700,
301+
.step_average_last_updated_time = 975,
282302
};
283303

284304
prv_create_card_and_render(&health_data);
@@ -299,6 +319,7 @@ void test_health_activity_summary_card__render_current_above_typical5(void) {
299319
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
300320
10, 10, 10, 10, 10, 50}, // 1000
301321
.current_step_average = 900,
322+
.step_average_last_updated_time = 975,
302323
};
303324

304325
prv_create_card_and_render(&health_data);
@@ -319,6 +340,7 @@ void test_health_activity_summary_card__render_current_above_expected(void) {
319340
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
320341
10, 10, 10, 10, 10, 50}, // 1000
321342
.current_step_average = 800,
343+
.step_average_last_updated_time = 975,
322344
};
323345

324346
prv_create_card_and_render(&health_data);
246 Bytes
247 Bytes
222 Bytes

0 commit comments

Comments
 (0)