Files
pebble/src/fw/apps/system_apps/health/health_detail_card.c
T
2025-01-29 00:03:29 -05:00

547 lines
21 KiB
C

/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "health_detail_card.h"
#include "applib/pbl_std/pbl_std.h"
#include "kernel/pbl_malloc.h"
#include "resource/resource_ids.auto.h"
#include "services/common/i18n/i18n.h"
#include "services/normal/activity/health_util.h"
#include "util/size.h"
#include "system/logging.h"
#define CORNER_RADIUS (3)
static void prv_draw_headings(HealthDetailCard *detail_card, GContext *ctx, const Layer *layer) {
const int16_t rect_padding = PBL_IF_RECT_ELSE(5, 22);
const int16_t rect_height = 35;
for (int i = 0; i < detail_card->num_headings; i++) {
HealthDetailHeading *heading = &detail_card->headings[i];
if (!heading->primary_label) {
continue;
}
#if PBL_ROUND
int16_t header_y_origin = ((detail_card->num_headings > 1) ? 22 : 32) + (i * (rect_height + 5));
#endif
GRect header_rect = grect_inset(layer->bounds, GEdgeInsets(rect_padding));
header_rect.origin.y += PBL_IF_RECT_ELSE(detail_card->y_origin, header_y_origin);
header_rect.size.h = rect_height;
detail_card->y_origin += rect_height + rect_padding;
#if PBL_BW
const GRect inner_rect = grect_inset(header_rect, GEdgeInsets(1));
const uint16_t inner_corner_radius = CORNER_RADIUS - 1;
graphics_context_set_stroke_color(ctx, heading->outline_color);
graphics_draw_round_rect(ctx, &inner_rect, inner_corner_radius);
graphics_draw_round_rect(ctx, &header_rect, CORNER_RADIUS);
#else
graphics_context_set_fill_color(ctx, heading->fill_color);
graphics_fill_round_rect(ctx, &header_rect, CORNER_RADIUS, GCornersAll);
#endif
const bool has_secondary_heading = heading->secondary_label;
GRect label_rect = header_rect;
if (has_secondary_heading) {
label_rect.size.w /= 2;
}
label_rect.size.h = 12; // Restrict to a single line
graphics_context_set_text_color(ctx, gcolor_legible_over(heading->fill_color));
graphics_draw_text(ctx, heading->primary_label, detail_card->heading_label_font,
label_rect, GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
const int16_t value_rect_y_padding = 12;
GRect value_rect = label_rect;
value_rect.origin.y += value_rect_y_padding;
graphics_draw_text(ctx, heading->primary_value, detail_card->heading_value_font,
value_rect, GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
if (!heading->secondary_label) {
continue;
}
const int separator_padding = 5;
GPoint separator_top_point = GPoint(header_rect.origin.x + (header_rect.size.w / 2) - 1,
header_rect.origin.y + separator_padding);
GPoint separator_bot_point = GPoint(separator_top_point.x, separator_top_point.y +
header_rect.size.h - (separator_padding * 2) - 1);
graphics_draw_line(ctx, separator_top_point, separator_bot_point);
// draw another line to make the width 2px
separator_top_point.x++;
separator_bot_point.x++;
graphics_draw_line(ctx, separator_top_point, separator_bot_point);
label_rect.origin.x += label_rect.size.w;
value_rect.origin.x += value_rect.size.w;
graphics_draw_text(ctx, heading->secondary_label, detail_card->heading_label_font,
label_rect, GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
graphics_draw_text(ctx, heading->secondary_value, detail_card->heading_value_font,
value_rect, GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
}
}
static void prv_draw_subtitles(HealthDetailCard *detail_card, GContext *ctx, const Layer *layer) {
const int16_t rect_padding = PBL_IF_RECT_ELSE(5, 0);
const int16_t rect_height = PBL_IF_RECT_ELSE(23, 36);
for (int i = 0; i < detail_card->num_subtitles; i++) {
HealthDetailSubtitle *subtitle = &detail_card->subtitles[i];
if (!subtitle->label) {
continue;
}
GRect subtitle_rect = grect_inset(layer->bounds, GEdgeInsets(rect_padding));
subtitle_rect.origin.y += PBL_IF_RECT_ELSE(detail_card->y_origin, 125);
subtitle_rect.size.h = rect_height;
detail_card->y_origin += rect_height + rect_padding;
graphics_context_set_fill_color(ctx, subtitle->fill_color);
graphics_fill_round_rect(ctx, &subtitle_rect, CORNER_RADIUS, GCornersAll);
if (!gcolor_equal(subtitle->outline_color, GColorClear)) {
graphics_context_set_stroke_color(ctx, subtitle->outline_color);
graphics_draw_round_rect(ctx, &subtitle_rect, CORNER_RADIUS);
}
// font offset
subtitle_rect.origin.y -= PBL_IF_RECT_ELSE(1, 3);
graphics_context_set_text_color(ctx, gcolor_legible_over(subtitle->fill_color));
graphics_draw_text(ctx, subtitle->label, detail_card->subtitle_font, subtitle_rect,
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
}
}
static void prv_draw_progress_bar(GContext *ctx, HealthProgressBar *progress_bar, GColor bg_color,
GColor fill_color, int current_progress, int typical_progress, int max_progress,
bool hide_typical) {
const GColor typical_color = PBL_IF_COLOR_ELSE(GColorYellow, GColorBlack);
const GColor outline_color = PBL_IF_COLOR_ELSE(GColorClear, GColorBlack);
health_progress_bar_fill(ctx, progress_bar, bg_color, 0, HEALTH_PROGRESS_BAR_MAX_VALUE);
if (max_progress > 0) {
const int current_fill = (current_progress * HEALTH_PROGRESS_BAR_MAX_VALUE) / max_progress;
health_progress_bar_fill(ctx, progress_bar, fill_color, 0, current_fill);
if (typical_progress > 0) {
const int typical_fill = (typical_progress * HEALTH_PROGRESS_BAR_MAX_VALUE) / max_progress;
health_progress_bar_mark(ctx, progress_bar, typical_color, typical_fill);
}
}
health_progress_bar_outline(ctx, progress_bar, outline_color);
}
#if PBL_RECT
static void prv_draw_progress_bar_in_zone(GContext *ctx, const GRect *zone_rect, GColor fill_color,
int current_progress, int typical_progress, int max_progress, bool hide_typical) {
const int16_t progress_bar_x = zone_rect->origin.x + PBL_IF_BW_ELSE(0, -1);
const int16_t progress_bar_y = zone_rect->origin.y + 22;
const int16_t progress_bar_width = zone_rect->size.w + PBL_IF_BW_ELSE(-2, 1);
const int16_t progress_bar_height = 10 + PBL_IF_BW_ELSE(-1, 0);
HealthProgressSegment segments[] = {
{
// Left side vertical line (needed for the draw outline function to draw the vertical lines)
.type = HealthProgressSegmentType_Corner,
.points = {
{progress_bar_x, progress_bar_y},
{progress_bar_x, progress_bar_y + progress_bar_height},
{progress_bar_x, progress_bar_y + progress_bar_height},
{progress_bar_x, progress_bar_y},
},
},
{
// Right side vertical line (needed for the draw outline function to draw the vertical lines)
.type = HealthProgressSegmentType_Corner,
.points = {
{progress_bar_x + progress_bar_width, progress_bar_y},
{progress_bar_x + progress_bar_width, progress_bar_y + progress_bar_height},
{progress_bar_x + progress_bar_width, progress_bar_y + progress_bar_height},
{progress_bar_x + progress_bar_width, progress_bar_y},
},
},
{
// Horizontal bar from left line to right line
.type = HealthProgressSegmentType_Horizontal,
.amount_of_total = HEALTH_PROGRESS_BAR_MAX_VALUE,
.mark_width = 124, // Arbitrarily chosen through trial and error
.points = {
{progress_bar_x, progress_bar_y + progress_bar_height},
{progress_bar_x + progress_bar_width, progress_bar_y + progress_bar_height},
{progress_bar_x + progress_bar_width, progress_bar_y},
{progress_bar_x, progress_bar_y},
},
},
};
HealthProgressBar progress_bar = {
.num_segments = ARRAY_LENGTH(segments),
.segments = segments,
};
const GColor bg_color = PBL_IF_COLOR_ELSE(GColorDarkGray, GColorWhite);
prv_draw_progress_bar(ctx, &progress_bar, bg_color, fill_color, current_progress,
typical_progress, max_progress, hide_typical);
}
static void prv_draw_zones(HealthDetailCard *detail_card, GContext *ctx) {
if (detail_card->num_zones <= 0) {
return;
}
const int16_t rect_padding = 5;
const int16_t rect_height = 33;
GRect zone_rect = grect_inset(detail_card->window.layer.bounds, GEdgeInsets(rect_padding));
zone_rect.origin.y += detail_card->y_origin;
zone_rect.size.h = rect_height;
for (int i = 0; i < detail_card->num_zones; i++) {
HealthDetailZone *zone = &detail_card->zones[i];
graphics_context_set_text_color(ctx, PBL_IF_COLOR_ELSE(GColorWhite, GColorBlack));
graphics_draw_text(ctx, zone->label, detail_card->subtitle_font, zone_rect,
GTextOverflowModeWordWrap, GTextAlignmentLeft, NULL);
if (zone->show_crown) {
const GSize label_size = app_graphics_text_layout_get_content_size(zone->label,
detail_card->subtitle_font, zone_rect, GTextOverflowModeWordWrap, GTextAlignmentLeft);
GPoint icon_offset = zone_rect.origin;
icon_offset.x += label_size.w + 4;
#if PBL_BW
icon_offset.y += 2;
#endif
gdraw_command_image_draw(ctx, detail_card->icon_crown, icon_offset);
}
prv_draw_progress_bar_in_zone(ctx, &zone_rect, zone->fill_color, zone->progress,
detail_card->daily_avg, detail_card->max_progress, zone->hide_typical);
zone_rect.origin.y += rect_height + rect_padding;
detail_card->y_origin += rect_height + rect_padding;
}
detail_card->y_origin += rect_padding;
}
#endif // PBL_RECT
#if PBL_ROUND
static uint16_t prv_get_num_rows_callback(MenuLayer *menu_layer,
uint16_t section_index, void *context) {
HealthDetailCard *detail_card = (HealthDetailCard *)context;
return detail_card->num_zones + 1;
}
static void prv_draw_row_callback(GContext *ctx, const Layer *cell_layer,
MenuIndex *cell_index, void *context) {
HealthDetailCard *detail_card = (HealthDetailCard *)context;
MenuIndex selected_index = menu_layer_get_selected_index(&detail_card->menu_layer);
if (cell_index->row == 0) {
graphics_context_set_fill_color(ctx, detail_card->bg_color);
graphics_fill_rect(ctx, &cell_layer->bounds);
prv_draw_headings(detail_card, ctx, cell_layer);
prv_draw_subtitles(detail_card, ctx, cell_layer);
return;
}
HealthDetailZone *zone = &detail_card->zones[cell_index->row - 1];
const int16_t rect_padding = 5;
GRect label_rect = grect_inset(cell_layer->bounds, GEdgeInsets(rect_padding));
if (!menu_layer_is_index_selected(&detail_card->menu_layer, cell_index)) {
label_rect.origin.y = (cell_index->row < selected_index.row) ? 3 : 22;
graphics_context_set_text_color(ctx, GColorWhite);
graphics_draw_text(ctx, zone->label, detail_card->subtitle_font, label_rect,
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
} else {
const GRect cell_bounds = grect_inset(cell_layer->bounds, GEdgeInsets(0, -1));
HealthProgressSegment segments[] = {
{
// Horizontal bar from left line to right line
.type = HealthProgressSegmentType_Horizontal,
.amount_of_total = HEALTH_PROGRESS_BAR_MAX_VALUE,
.mark_width = 100, // Arbitrarily chosen through trial and error
.points = {
{cell_bounds.origin.x, cell_bounds.size.h},
{cell_bounds.size.w, cell_bounds.size.h},
{cell_bounds.size.w, cell_bounds.origin.y},
{cell_bounds.origin.x, cell_bounds.origin.y},
},
},
};
HealthProgressBar progress_bar = {
.num_segments = ARRAY_LENGTH(segments),
.segments = segments,
};
prv_draw_progress_bar(ctx, &progress_bar, GColorLightGray, zone->fill_color, zone->progress,
detail_card->daily_avg, detail_card->max_progress, zone->hide_typical);
label_rect.origin.y += 3;
if (zone->show_crown) {
const GSize icon_size = gdraw_command_image_get_bounds_size(detail_card->icon_crown);
GPoint icon_offset = GPoint((cell_layer->bounds.size.w / 2) - (icon_size.w / 2), 4);
gdraw_command_image_draw(ctx, detail_card->icon_crown, icon_offset);
label_rect.origin.y += 8;
}
graphics_context_set_text_color(ctx, GColorBlack);
graphics_draw_text(ctx, zone->label, fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD), label_rect,
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
}
}
static int16_t prv_get_cell_height_callback(MenuLayer *menu_layer,
MenuIndex *cell_index, void *context) {
if (cell_index->row == 0) {
return menu_layer_is_index_selected(menu_layer, cell_index) ? DISP_ROWS : 0;
}
return menu_layer_is_index_selected(menu_layer, cell_index) ? 50 : 54;
}
static void prv_refresh_content_indicators(HealthDetailCard *detail_card) {
const bool is_up_visible = (menu_layer_get_selected_index(&detail_card->menu_layer).row > 0);
const bool is_down_visible = (menu_layer_get_selected_index(&detail_card->menu_layer).row <
prv_get_num_rows_callback(&detail_card->menu_layer, 0, detail_card) - 1);
content_indicator_set_content_available(&detail_card->up_indicator,
ContentIndicatorDirectionUp,
is_up_visible);
content_indicator_set_content_available(&detail_card->down_indicator,
ContentIndicatorDirectionDown,
is_down_visible);
}
static void prv_selection_changed_callback(struct MenuLayer *menu_layer, MenuIndex new_index,
MenuIndex old_index, void *context) {
HealthDetailCard *detail_card = (HealthDetailCard *)context;
prv_refresh_content_indicators(detail_card);
}
#else // PBL_RECT
static void prv_health_detail_scroll_layer_update_proc(Layer *layer, GContext *ctx) {
ScrollLayer *scroll_layer = (ScrollLayer *)layer->parent;
HealthDetailCard *detail_card = (HealthDetailCard *)scroll_layer->context;
detail_card->y_origin = 0;
prv_draw_headings(detail_card, ctx, &detail_card->window.layer);
prv_draw_subtitles(detail_card, ctx, &detail_card->window.layer);
prv_draw_zones(detail_card, ctx);
scroll_layer_set_content_size(&detail_card->scroll_layer,
GSize(layer->bounds.size.w, detail_card->y_origin));
}
#endif // PBL_RECT
HealthDetailCard *health_detail_card_create(const HealthDetailCardConfig *config) {
HealthDetailCard *detail_card = app_zalloc_check(sizeof(HealthDetailCard));
window_init(&detail_card->window, WINDOW_NAME("Health Detail Card"));
health_detail_card_configure(detail_card, config);
GRect window_frame = detail_card->window.layer.frame;
#if PBL_ROUND
// setup menu layer
MenuLayer *menu_layer = &detail_card->menu_layer;
menu_layer_init(menu_layer, &window_frame);
menu_layer_set_callbacks(menu_layer, detail_card, &(MenuLayerCallbacks) {
.get_num_rows = prv_get_num_rows_callback,
.get_cell_height = prv_get_cell_height_callback,
.draw_row = prv_draw_row_callback,
.selection_changed = prv_selection_changed_callback,
});
menu_layer_set_normal_colors(menu_layer, detail_card->bg_color, GColorWhite);
menu_layer_set_highlight_colors(menu_layer, detail_card->bg_color, GColorBlack);
menu_layer_set_click_config_onto_window(menu_layer, &detail_card->window);
layer_add_child(&detail_card->window.layer, menu_layer_get_layer(menu_layer));
// setup content indicators
const int content_indicator_height = 15;
const GRect down_arrow_layer_frame = grect_inset(window_frame,
GEdgeInsets(window_frame.size.h - content_indicator_height, 0, 0));
layer_init(&detail_card->down_arrow_layer, &down_arrow_layer_frame);
layer_add_child(&detail_card->window.layer, &detail_card->down_arrow_layer);
content_indicator_init(&detail_card->down_indicator);
const GRect up_arrow_layer_frame = grect_inset(window_frame,
GEdgeInsets(0, 0, window_frame.size.h - content_indicator_height));
layer_init(&detail_card->up_arrow_layer, &up_arrow_layer_frame);
layer_add_child(&detail_card->window.layer, &detail_card->up_arrow_layer);
content_indicator_init(&detail_card->up_indicator);
ContentIndicatorConfig content_indicator_config = (ContentIndicatorConfig) {
.layer = &detail_card->up_arrow_layer,
.colors.foreground = gcolor_legible_over(detail_card->bg_color),
.colors.background = detail_card->bg_color,
};
content_indicator_configure_direction(&detail_card->up_indicator, ContentIndicatorDirectionUp,
&content_indicator_config);
content_indicator_config.layer = &detail_card->down_arrow_layer;
content_indicator_configure_direction(&detail_card->down_indicator, ContentIndicatorDirectionDown,
&content_indicator_config);
prv_refresh_content_indicators(detail_card);
#else // PBL_RECT
// setup scroll layer
scroll_layer_init(&detail_card->scroll_layer, &window_frame);
scroll_layer_set_click_config_onto_window(&detail_card->scroll_layer, &detail_card->window);
scroll_layer_set_context(&detail_card->scroll_layer, detail_card);
scroll_layer_set_shadow_hidden(&detail_card->scroll_layer, true);
layer_add_child(&detail_card->window.layer, (Layer *)&detail_card->scroll_layer);
layer_set_update_proc(&detail_card->scroll_layer.content_sublayer,
prv_health_detail_scroll_layer_update_proc);
#endif // PBL_RECT
detail_card->icon_crown = gdraw_command_image_create_with_resource(RESOURCE_ID_HEALTH_APP_CROWN);
detail_card->heading_label_font = fonts_get_system_font(FONT_KEY_GOTHIC_14_BOLD);
detail_card->heading_value_font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD);
detail_card->subtitle_font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD);
return detail_card;
}
void health_detail_card_destroy(HealthDetailCard *detail_card) {
if (!detail_card) {
return;
}
gdraw_command_image_destroy(detail_card->icon_crown);
#if PBL_ROUND
menu_layer_deinit(&detail_card->menu_layer);
content_indicator_deinit(&detail_card->down_indicator);
content_indicator_deinit(&detail_card->up_indicator);
#else
scroll_layer_deinit(&detail_card->scroll_layer);
#endif
i18n_free_all(detail_card);
app_free(detail_card);
}
void health_detail_card_configure(HealthDetailCard *detail_card,
const HealthDetailCardConfig *config) {
if (!detail_card || !config) {
return;
}
detail_card->bg_color = config->bg_color;
window_set_background_color(&detail_card->window, detail_card->bg_color);
if (config->num_headings) {
detail_card->num_headings = config->num_headings;
detail_card->headings = config->headings;
}
if (config->num_subtitles) {
detail_card->num_subtitles = config->num_subtitles;
detail_card->subtitles = config->subtitles;
}
detail_card->daily_avg = config->daily_avg;
detail_card->max_progress = MAX(config->weekly_max, detail_card->daily_avg);
detail_card->max_progress = detail_card->max_progress * 11 / 10; // add 10%;
if (config->num_zones) {
detail_card->num_zones = config->num_zones;
detail_card->zones = config->zones;
}
if (config->data) {
detail_card->data = config->data;
}
}
void health_detail_card_set_render_day_zones(HealthDetailZone *zones, int16_t *num_zones,
int32_t *weekly_max, bool format_hours_and_minutes, bool show_crown, GColor fill_color,
GColor today_fill_color, int32_t *day_data, void *i18n_owner) {
time_t time_utc = rtc_get_time();
struct tm time_tm;
int max_data = 0;
int crown_index = 0;
*num_zones = DAYS_PER_WEEK;
for (int i = 0; i < *num_zones; i++) {
localtime_r(&time_utc, &time_tm);
const bool is_today = (i == 0);
const size_t buffer_size = 32;
zones[i] = (HealthDetailZone) {
.label = app_zalloc_check(buffer_size),
.progress = day_data[i],
.fill_color = is_today ? PBL_IF_ROUND_ELSE(fill_color, today_fill_color) : fill_color,
.hide_typical = is_today,
};
char *label_ptr = zones[i].label;
int pos = 0;
if (i == 0) {
pos += snprintf(label_ptr, buffer_size, "%s ", i18n_get("Today", i18n_owner));
} else {
pos += strftime(label_ptr, buffer_size, "%a ", &time_tm);
}
if (day_data[i] > 0) {
if (format_hours_and_minutes) {
health_util_format_hours_and_minutes(label_ptr + pos, buffer_size - pos, day_data[i],
i18n_owner);
} else {
snprintf(label_ptr + pos, buffer_size - pos, "%"PRId32, day_data[i]);
}
}
if (day_data[i] > *weekly_max) {
*weekly_max = day_data[i];
}
if (day_data[i] > max_data) {
max_data = day_data[i];
crown_index = i;
}
time_utc -= SECONDS_PER_DAY;
}
if (crown_index && show_crown) {
zones[crown_index].show_crown = true;
}
}