diff --git a/src/c/main.c b/src/c/main.c index 56a84f8..2c98f63 100644 --- a/src/c/main.c +++ b/src/c/main.c @@ -94,6 +94,7 @@ static QuotaRequestSource s_active_quota_request_source = QUOTA_REQUEST_STARTUP; static QuotaUnloadedState s_unloaded_state = QUOTA_UNLOADED_LOADING; static int64_t s_last_success_epoch_seconds = 0; static int64_t s_last_automatic_attempt_epoch_seconds = 0; +static int64_t s_display_epoch_seconds = 0; static QuotaSnapshot s_quota_snapshot; static uint8_t s_snapshot_transfer[QUOTA_SNAPSHOT_SERIALIZED_MAX_SIZE]; static int32_t s_snapshot_transfer_id = 0; @@ -105,9 +106,10 @@ static int s_snapshot_transfer_next_chunk = 0; static bool s_snapshot_transfer_active = false; static char s_title_text[QUOTA_TITLE_LENGTH] = "Pebblexus"; -static char s_reset_text[QUOTA_FOOTER_LENGTH] = "Connecting to phone"; +static char s_unloaded_detail_text[QUOTA_FOOTER_LENGTH] = "Connecting to phone"; +static char s_footer_text[QUOTA_FOOTER_LENGTH] = ""; +static char s_footer_failure[QUOTA_FOOTER_LENGTH] = ""; static char s_status_text[QUOTA_STATUS_LENGTH] = "Waiting"; -static char s_updated_text[QUOTA_UPDATED_LENGTH] = ""; static QuotaMeter s_rows[MAX_ROWS]; static int clamp_percent(int percent) { @@ -361,52 +363,38 @@ static bool quota_snapshot_storage_is_available(void) { return storage_size > 0 && snapshot_store_storage_fits("A_SNAPSHOT_STORE, PERSIST_DATA_MAX_LENGTH, storage_size); } +static void update_footer_text(void) { + if (s_row_count < 1) { + s_footer_text[0] = '\0'; + return; + } + if (!quota_snapshot_format_footer(s_quota_snapshot.fetched_epoch_seconds, s_display_epoch_seconds, + s_showing_cached_data, s_footer_failure, s_footer_text, sizeof(s_footer_text))) + snprintf(s_footer_text, sizeof(s_footer_text), "%s", s_showing_cached_data ? "Cached" : "Updated"); +} + static bool show_cached_footer(const char *reason) { if (s_row_count < 1) return false; s_showing_cached_data = true; - if (!quota_snapshot_format_cached_footer(s_updated_text, reason, s_reset_text, sizeof(s_reset_text)) && - !quota_snapshot_format_cached_footer("", reason, s_reset_text, sizeof(s_reset_text))) - snprintf(s_reset_text, sizeof(s_reset_text), "Cached"); + snprintf(s_footer_failure, sizeof(s_footer_failure), "%s", reason != NULL ? reason : ""); + update_footer_text(); return true; } -static void format_updated_time(uint32_t epoch_seconds) { - time_t fetched = (time_t)epoch_seconds; - struct tm *local = localtime(&fetched); - if (local == NULL) { - s_updated_text[0] = '\0'; - return; - } - snprintf(s_updated_text, sizeof(s_updated_text), "at %d:%02d", local->tm_hour, local->tm_min); +static void update_display_time(void) { + time_t now = time(NULL); + if (now > 0) + s_display_epoch_seconds = (int64_t)now; + update_footer_text(); } -static void format_short_reset(const QuotaGroup *group) { - uint32_t reset_epoch_seconds = 0; - for (int meter_index = 0; meter_index < group->meter_count; meter_index += 1) { - const QuotaMeter *meter = &s_quota_snapshot.meters[group->meter_start + meter_index]; - if (meter->reset_epoch_seconds > 0 && - (reset_epoch_seconds == 0 || meter->reset_epoch_seconds < reset_epoch_seconds)) - reset_epoch_seconds = meter->reset_epoch_seconds; - } - if (reset_epoch_seconds == 0) { - snprintf(s_reset_text, sizeof(s_reset_text), "%s", s_updated_text); +static void minute_tick_handler(struct tm *tick_time, TimeUnits units_changed) { + if (tick_time == NULL || (units_changed & MINUTE_UNIT) == 0) return; - } - - time_t now = time(NULL); - int64_t difference_seconds = (int64_t)reset_epoch_seconds - (int64_t)now; - bool future = difference_seconds >= 0; - uint64_t absolute_minutes = (uint64_t)(future ? difference_seconds : -difference_seconds) / 60; - const char *prefix = future ? "Next reset " : "Reset "; - if (absolute_minutes < 90) { - snprintf(s_reset_text, sizeof(s_reset_text), "%s%lum", prefix, (unsigned long)absolute_minutes); - } else if (absolute_minutes < 48 * 60) { - snprintf(s_reset_text, sizeof(s_reset_text), "%s%luh", prefix, (unsigned long)(absolute_minutes / 60)); - } else { - snprintf(s_reset_text, sizeof(s_reset_text), "%s%lud", prefix, (unsigned long)(absolute_minutes / (24 * 60))); - } + update_display_time(); + mark_card_dirty(); } static void present_current_group(void) { @@ -443,10 +431,7 @@ static void present_current_group(void) { else snprintf(s_status_text, sizeof(s_status_text), "OK"); - format_updated_time(s_quota_snapshot.fetched_epoch_seconds); - format_short_reset(group); - if (s_showing_cached_data) - show_cached_footer(NULL); + update_footer_text(); } static int find_group(const char *id) { @@ -509,9 +494,8 @@ static void request_quota(QuotaRequestSource source) { show_cached_footer(NULL); } else { s_unloaded_state = QUOTA_UNLOADED_LOADING; - snprintf(s_reset_text, sizeof(s_reset_text), "Connecting to phone"); + snprintf(s_unloaded_detail_text, sizeof(s_unloaded_detail_text), "Connecting to phone"); snprintf(s_status_text, sizeof(s_status_text), "Refreshing"); - s_updated_text[0] = '\0'; } s_is_refreshing = true; s_refresh_frame = 0; @@ -733,13 +717,14 @@ static void card_update_proc(Layer *layer, GContext *ctx) { heading = "Loading quotas"; draw_text(ctx, heading, GRect(content_x, y + 48, content_w, 34), fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD), color_text_primary(), GTextAlignmentCenter); - draw_text(ctx, s_reset_text, GRect(content_x, y + 86, content_w, 46), fonts_get_system_font(FONT_KEY_GOTHIC_24), - color_text_secondary(), GTextAlignmentCenter); + draw_text(ctx, s_unloaded_detail_text, GRect(content_x, y + 86, content_w, 46), + fonts_get_system_font(FONT_KEY_GOTHIC_24), color_text_secondary(), GTextAlignmentCenter); } else { - // Row layout heights (42/28/50 + 3px separators) are mirrored by the - // phone-side page packing in src/pkjs/index.js; keep them in sync. + // Base row heights are 42/28/50px. A meter reset line adds 18px, and + // separators add 3px between rows. GFont row_font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD); - int row_bottom_limit = bounds.size.h - 34 + offset; + GFont reset_font = fonts_get_system_font(FONT_KEY_GOTHIC_14); + int row_bottom_limit = bounds.size.h - 20 + offset; // One detail column for all bar rows keeps the bar tracks aligned. int detail_column_w = 28; @@ -757,8 +742,12 @@ static void card_update_proc(Layer *layer, GContext *ctx) { for (int i = 0; i < s_row_count && i < MAX_ROWS; i += 1) { QuotaMeter *row = &s_rows[i]; GColor accent = color_for_status(row->status_rank); + char reset_text[QUOTA_FOOTER_LENGTH]; + bool has_reset = quota_snapshot_format_reset(row->reset_epoch_seconds, s_display_epoch_seconds, reset_text, + sizeof(reset_text)); + int more_height = i + 1 < s_row_count ? 25 : 0; if (row->has_bar) { - if (y + 42 > row_bottom_limit) + if (y + 42 + (has_reset ? 18 : 0) + more_height > row_bottom_limit) break; draw_text(ctx, row->label, GRect(content_x, y, content_w, 22), row_font, color_text_primary(), GTextAlignmentLeft); @@ -774,7 +763,7 @@ static void card_update_proc(Layer *layer, GContext *ctx) { detail_w = 100; int label_w = content_w - detail_w - 8; bool two_line = text_width(row->label, row_font) > label_w; - if (y + (two_line ? 50 : 28) > row_bottom_limit) + if (y + (two_line ? 50 : 28) + (has_reset ? 18 : 0) + more_height > row_bottom_limit) break; if (two_line) { draw_text(ctx, row->label, GRect(content_x, y, content_w, 22), row_font, color_text_primary(), @@ -791,6 +780,11 @@ static void card_update_proc(Layer *layer, GContext *ctx) { y += 28; } } + if (has_reset) { + draw_text(ctx, reset_text, GRect(content_x, y - 3, content_w, 18), reset_font, color_text_muted(), + GTextAlignmentLeft); + y += 18; + } rows_drawn += 1; if (i + 1 < s_row_count && i + 1 < MAX_ROWS) { graphics_context_set_stroke_color(ctx, color_card_outline()); @@ -810,8 +804,8 @@ static void card_update_proc(Layer *layer, GContext *ctx) { if (s_row_count > 0) { GColor footer_color = s_showing_cached_data ? color_for_status(3) : color_text_muted(); - draw_text(ctx, s_reset_text, GRect(content_x, bounds.size.h - 32 + offset, content_w, 24), - fonts_get_system_font(FONT_KEY_GOTHIC_18), footer_color, GTextAlignmentCenter); + draw_text(ctx, s_footer_text, GRect(content_x, bounds.size.h - 20 + offset, content_w, 18), + fonts_get_system_font(FONT_KEY_GOTHIC_14), footer_color, GTextAlignmentCenter); } } @@ -833,9 +827,8 @@ static void fail_snapshot_transfer(const char *reason) { s_page_count = 0; s_unloaded_state = QUOTA_UNLOADED_RETRYABLE; snprintf(s_title_text, sizeof(s_title_text), "Pebblexus"); - snprintf(s_reset_text, sizeof(s_reset_text), "%s", reason); + snprintf(s_unloaded_detail_text, sizeof(s_unloaded_detail_text), "%s", reason); snprintf(s_status_text, sizeof(s_status_text), "Select retries"); - s_updated_text[0] = '\0'; } snap_motion_idle(); if (was_refreshing && s_active_quota_request_source == QUOTA_REQUEST_MANUAL) @@ -914,7 +907,9 @@ static void finish_snapshot_transfer(void) { } s_is_refreshing = false; s_showing_cached_data = false; + s_footer_failure[0] = '\0'; s_last_success_epoch_seconds = s_quota_snapshot.fetched_epoch_seconds; + update_display_time(); int preserved_group_index = find_group(selected_group_id); s_page_index = preserved_group_index >= 0 ? preserved_group_index : 0; present_current_group(); @@ -972,14 +967,13 @@ static void inbox_received_callback(DictionaryIterator *iterator, void *context) error_kind = QUOTA_ERROR_SETUP_REQUIRED; if (error_kind == QUOTA_ERROR_SETUP_REQUIRED) { s_unloaded_state = QUOTA_UNLOADED_SETUP_REQUIRED; - snprintf(s_reset_text, sizeof(s_reset_text), "Open settings in Pebble app"); + snprintf(s_unloaded_detail_text, sizeof(s_unloaded_detail_text), "Open settings in Pebble app"); s_status_text[0] = '\0'; } else { s_unloaded_state = QUOTA_UNLOADED_RETRYABLE; - snprintf(s_reset_text, sizeof(s_reset_text), "%s", error_tuple->value->cstring); + snprintf(s_unloaded_detail_text, sizeof(s_unloaded_detail_text), "%s", error_tuple->value->cstring); snprintf(s_status_text, sizeof(s_status_text), "Select retries"); } - s_updated_text[0] = '\0'; snap_motion_idle(); mark_card_dirty(); return; @@ -1032,6 +1026,7 @@ static void main_window_unload(Window *window) { } static void init(void) { + update_display_time(); load_quota_snapshot(); s_main_window = window_create(); @@ -1046,10 +1041,12 @@ static void init(void) { window_stack_push(s_main_window, true); request_quota(QUOTA_REQUEST_STARTUP); + tick_timer_service_subscribe(MINUTE_UNIT, minute_tick_handler); backlight_service_subscribe(backlight_handler); } static void deinit(void) { + tick_timer_service_unsubscribe(); backlight_service_unsubscribe(); window_destroy(s_main_window); } diff --git a/src/c/quota_snapshot.c b/src/c/quota_snapshot.c index 295264b..f5b225e 100644 --- a/src/c/quota_snapshot.c +++ b/src/c/quota_snapshot.c @@ -145,24 +145,93 @@ bool quota_snapshot_deserialize(const uint8_t *serialization, size_t serializati return true; } -bool quota_snapshot_format_cached_footer(const char *updated, const char *reason, char *buffer, size_t buffer_size) { - if (updated == NULL || buffer == NULL || buffer_size == 0) +static bool write_format_result(int length, char *buffer, size_t buffer_size) { + if (buffer == NULL || buffer_size == 0) return false; - - const char *display_updated = strncmp(updated, "at ", 3) == 0 ? updated + 3 : updated; - int length; - if (display_updated[0] != '\0' && reason != NULL && reason[0] != '\0') - length = snprintf(buffer, buffer_size, "Cached · %s · %s", display_updated, reason); - else if (display_updated[0] != '\0') - length = snprintf(buffer, buffer_size, "Cached · %s", display_updated); - else if (reason != NULL && reason[0] != '\0') - length = snprintf(buffer, buffer_size, "Cached · %s", reason); - else - length = snprintf(buffer, buffer_size, "Cached"); - if (length < 0 || (size_t)length >= buffer_size) { buffer[0] = '\0'; return false; } return true; } + +static bool format_elapsed(uint64_t seconds, char *buffer, size_t buffer_size) { + if (buffer == NULL || buffer_size == 0) + return false; + + uint32_t bounded_seconds = seconds > UINT32_MAX ? UINT32_MAX : (uint32_t)seconds; + if (bounded_seconds < 60) + return write_format_result(snprintf(buffer, buffer_size, "<1m"), buffer, buffer_size); + + uint32_t minutes = bounded_seconds / 60; + if (minutes < 60) + return write_format_result(snprintf(buffer, buffer_size, "%lum", (unsigned long)minutes), buffer, buffer_size); + + uint32_t hours = minutes / 60; + if (hours < 24) { + uint32_t remaining_minutes = minutes % 60; + int length = remaining_minutes > 0 ? snprintf(buffer, buffer_size, "%luh %lum", (unsigned long)hours, + (unsigned long)remaining_minutes) + : snprintf(buffer, buffer_size, "%luh", (unsigned long)hours); + return write_format_result(length, buffer, buffer_size); + } + + uint32_t days = hours / 24; + uint32_t remaining_hours = hours % 24; + int length = remaining_hours > 0 + ? snprintf(buffer, buffer_size, "%lud %luh", (unsigned long)days, (unsigned long)remaining_hours) + : snprintf(buffer, buffer_size, "%lud", (unsigned long)days); + return write_format_result(length, buffer, buffer_size); +} + +bool quota_snapshot_format_reset(uint32_t reset_epoch_seconds, int64_t now_epoch_seconds, char *buffer, + size_t buffer_size) { + if (buffer == NULL || buffer_size == 0) + return false; + buffer[0] = '\0'; + if (reset_epoch_seconds == 0 || now_epoch_seconds <= 0) + return false; + + int64_t remaining_seconds = (int64_t)reset_epoch_seconds - now_epoch_seconds; + if (remaining_seconds <= 0) + return write_format_result(snprintf(buffer, buffer_size, "Reset due"), buffer, buffer_size); + + char elapsed[24]; + if (!format_elapsed((uint64_t)remaining_seconds, elapsed, sizeof(elapsed))) + return false; + const char *format = remaining_seconds < 60 ? "Resets %s" : "Resets in %s"; + return write_format_result(snprintf(buffer, buffer_size, format, elapsed), buffer, buffer_size); +} + +bool quota_snapshot_format_footer(uint32_t fetched_epoch_seconds, int64_t now_epoch_seconds, bool cached, + const char *failure, char *buffer, size_t buffer_size) { + if (buffer == NULL || buffer_size == 0) + return false; + buffer[0] = '\0'; + if (fetched_epoch_seconds == 0 || now_epoch_seconds <= 0) + return false; + + uint64_t elapsed_seconds = + now_epoch_seconds > (int64_t)fetched_epoch_seconds ? (uint64_t)(now_epoch_seconds - fetched_epoch_seconds) : 0; + char elapsed[24]; + if (!format_elapsed(elapsed_seconds, elapsed, sizeof(elapsed))) + return false; + + char updated[32]; + if (!write_format_result(snprintf(updated, sizeof(updated), "Updated %s ago", elapsed), updated, sizeof(updated))) + return false; + + const char *suffix = failure != NULL && failure[0] != '\0' ? failure : cached ? "Cached" : NULL; + if (suffix != NULL) { + int length = snprintf(buffer, buffer_size, "%s · %s", updated, suffix); + if (length >= 0 && (size_t)length < buffer_size) + return true; + } + if (cached && suffix != NULL && strcmp(suffix, "Cached") != 0) { + int length = snprintf(buffer, buffer_size, "%s · Cached", updated); + if (length >= 0 && (size_t)length < buffer_size) + return true; + } + + return write_format_result(snprintf(buffer, buffer_size, "%s", updated), buffer, buffer_size); +} diff --git a/src/c/quota_snapshot.h b/src/c/quota_snapshot.h index 6855250..c4fc57a 100644 --- a/src/c/quota_snapshot.h +++ b/src/c/quota_snapshot.h @@ -14,7 +14,6 @@ #define QUOTA_METER_LABEL_LENGTH 24 #define QUOTA_METER_DETAIL_LENGTH 28 #define QUOTA_STATUS_LENGTH 16 -#define QUOTA_UPDATED_LENGTH 16 #define QUOTA_FOOTER_LENGTH 40 // Keep these wire sizes synchronized with src/pkjs/index.js. Their maxima fit // two complete checksummed snapshots in the minimum 4 KiB persist budget. @@ -55,6 +54,9 @@ typedef struct { uint32_t quota_snapshot_checksum(const uint8_t *bytes, size_t size); bool quota_snapshot_is_valid(const uint8_t *serialization, size_t serialization_size); bool quota_snapshot_deserialize(const uint8_t *serialization, size_t serialization_size, QuotaSnapshot *snapshot); -bool quota_snapshot_format_cached_footer(const char *updated, const char *reason, char *buffer, size_t buffer_size); +bool quota_snapshot_format_reset(uint32_t reset_epoch_seconds, int64_t now_epoch_seconds, char *buffer, + size_t buffer_size); +bool quota_snapshot_format_footer(uint32_t fetched_epoch_seconds, int64_t now_epoch_seconds, bool cached, + const char *failure, char *buffer, size_t buffer_size); #endif diff --git a/test/quota_snapshot.test.c b/test/quota_snapshot.test.c index b923384..304f7bc 100644 --- a/test/quota_snapshot.test.c +++ b/test/quota_snapshot.test.c @@ -1,6 +1,7 @@ #include "quota_snapshot.h" #include +#include #include #define HEADER_SIZE 20 @@ -159,16 +160,42 @@ static void test_checksum(void) { assert(quota_snapshot_checksum(NULL, 0) == 0); } -static void test_cached_footer(void) { +static void test_reset_time(void) { char footer[QUOTA_FOOTER_LENGTH]; + int64_t now = INT64_C(1784826720); + + assert(quota_snapshot_format_reset(UINT32_C(1784841840), now, footer, sizeof(footer))); + assert(strcmp(footer, "Resets in 4h 12m") == 0); + assert(quota_snapshot_format_reset(UINT32_C(1785355920), now, footer, sizeof(footer))); + assert(strcmp(footer, "Resets in 6d 3h") == 0); + assert(quota_snapshot_format_reset(UINT32_C(1784826750), now, footer, sizeof(footer))); + assert(strcmp(footer, "Resets <1m") == 0); + assert(quota_snapshot_format_reset(UINT32_C(1784826720), now, footer, sizeof(footer))); + assert(strcmp(footer, "Reset due") == 0); + + snprintf(footer, sizeof(footer), "invalid"); + assert(!quota_snapshot_format_reset(0, now, footer, sizeof(footer))); + assert(footer[0] == '\0'); +} - assert(quota_snapshot_format_cached_footer("at 14:32", NULL, footer, sizeof(footer))); - assert(strcmp(footer, "Cached · 14:32") == 0); - assert(quota_snapshot_format_cached_footer("at 14:32", "Disconnected", footer, sizeof(footer))); - assert(strcmp(footer, "Cached · 14:32 · Disconnected") == 0); +static void test_updated_footer(void) { + char footer[QUOTA_FOOTER_LENGTH]; + int64_t now = INT64_C(1784826720); + + assert(quota_snapshot_format_footer(UINT32_C(1784826720), now, false, NULL, footer, sizeof(footer))); + assert(strcmp(footer, "Updated <1m ago") == 0); + assert(quota_snapshot_format_footer(UINT32_C(1784826480), now, true, NULL, footer, sizeof(footer))); + assert(strcmp(footer, "Updated 4m ago · Cached") == 0); + assert(quota_snapshot_format_footer(UINT32_C(1784819340), now, true, "Dropped", footer, sizeof(footer))); + assert(strcmp(footer, "Updated 2h 3m ago · Dropped") == 0); + assert(quota_snapshot_format_footer(UINT32_C(1784301120), now, false, NULL, footer, sizeof(footer))); + assert(strcmp(footer, "Updated 6d 2h ago") == 0); + assert(quota_snapshot_format_footer(UINT32_C(1784826720), now, true, "An exceptionally verbose transfer failure", + footer, sizeof(footer))); + assert(strcmp(footer, "Updated <1m ago · Cached") == 0); char short_footer[8] = "invalid"; - assert(!quota_snapshot_format_cached_footer("at 14:32", "Disconnected", short_footer, sizeof(short_footer))); + assert(!quota_snapshot_format_footer(UINT32_C(1784826720), now, false, NULL, short_footer, sizeof(short_footer))); assert(short_footer[0] == '\0'); } @@ -176,6 +203,7 @@ int main(void) { test_valid_snapshot(); test_invalid_snapshots(); test_checksum(); - test_cached_footer(); + test_reset_time(); + test_updated_footer(); return 0; }