From c27cd0bccab91fbccc91a5a24af60076739d413c Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 26 Jul 2026 08:40:35 -0600 Subject: [PATCH] ui: guide first-time setup Distinguish missing phone-side configuration from retryable data failures in the AppMessage protocol. Show first-time users a dedicated setup state with a phone glyph and an instruction to open settings in the Pebble app. Remove the misleading retry prompt and empty page marker while preserving cached quota fallback and retryable failure behavior. Cover missing credentials and network failures in the PebbleKit JS tests. --- package.json | 3 ++- src/c/main.c | 69 ++++++++++++++++++++++++++++++++++++++--------- src/pkjs/index.js | 19 ++++++++----- test/pkjs.test.js | 40 ++++++++++++++++++++++++++- 4 files changed, 109 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 0057bb6..07267bc 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "PAGE_INDEX", "PAGE_COUNT", "UPDATED_AT", - "ERROR" + "ERROR", + "ERROR_KIND" ], "resources": { "media": [] diff --git a/src/c/main.c b/src/c/main.c index a529055..966bf56 100644 --- a/src/c/main.c +++ b/src/c/main.c @@ -58,6 +58,17 @@ typedef enum { QUOTA_REQUEST_BACKLIGHT, } QuotaRequestSource; +typedef enum { + QUOTA_ERROR_RETRYABLE = 0, + QUOTA_ERROR_SETUP_REQUIRED = 1, +} QuotaErrorKind; + +typedef enum { + QUOTA_UNLOADED_LOADING, + QUOTA_UNLOADED_RETRYABLE, + QUOTA_UNLOADED_SETUP_REQUIRED, +} QuotaUnloadedState; + static Window *s_main_window; static Layer *s_card_layer; static AppTimer *s_animation_timer; @@ -81,6 +92,7 @@ static PressPhase s_press_phase = PRESS_NONE; static int s_press_frame = 0; static int s_press_offset_x = 0; 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; @@ -570,6 +582,8 @@ static void request_quota(QuotaRequestSource source) { if (s_showing_cached_data) show_cached_footer(NULL); } else { + s_unloaded_state = QUOTA_UNLOADED_LOADING; + snprintf(s_reset_text, sizeof(s_reset_text), "Connecting to phone"); snprintf(s_status_text, sizeof(s_status_text), "Refreshing"); s_updated_text[0] = '\0'; } @@ -694,6 +708,16 @@ static void draw_status_pill(GContext *ctx, GRect frame) { fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), color_text_primary(), GTextAlignmentCenter); } +static void draw_setup_phone(GContext *ctx, GPoint center) { + GRect phone = GRect(center.x - 10, center.y - 17, 20, 34); + graphics_context_set_fill_color(ctx, color_card_highlight()); + graphics_fill_rect(ctx, phone, 3, GCornersAll); + graphics_context_set_fill_color(ctx, color_background()); + graphics_fill_rect(ctx, GRect(phone.origin.x + 3, phone.origin.y + 5, phone.size.w - 6, phone.size.h - 11), 1, + GCornersAll); + graphics_fill_circle(ctx, GPoint(center.x, phone.origin.y + phone.size.h - 3), 1); +} + // In-flight pump stroke: a short segment strokes back and forth along the // horizontal pump axis inside the status corner the page count usually owns. // It decelerates into each end like a piston hitting the end of its travel, so @@ -752,7 +776,7 @@ static void card_update_proc(Layer *layer, GContext *ctx) { draw_text(ctx, s_title_text, GRect(content_x, y, content_w - 50, 29), fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD), color_text_primary(), GTextAlignmentLeft); - char page_text[16]; + char page_text[16] = ""; if (s_page_count > 0) { int display_page_index = s_page_index + 1; if (display_page_index < 1) @@ -765,18 +789,16 @@ static void card_update_proc(Layer *layer, GContext *ctx) { if (display_page_count > 99) display_page_count = 99; snprintf(page_text, sizeof(page_text), "%d/%d", display_page_index, display_page_count); - } else { - snprintf(page_text, sizeof(page_text), "--"); } GRect status_corner = GRect(content_x + content_w - 46, y + 2, 46, 24); if (s_is_refreshing) { draw_refresh_pump(ctx, status_corner); - } else { + } else if (s_page_count > 0) { draw_text(ctx, page_text, status_corner, fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), color_text_secondary(), GTextAlignmentRight); } - bool initial_loading = s_row_count <= 0 && s_is_refreshing; + bool initial_loading = s_row_count <= 0 && s_is_refreshing && s_unloaded_state == QUOTA_UNLOADED_LOADING; bool show_status_pill = s_status_text[0] != '\0' && !initial_loading; y += 26; if (show_status_pill) { @@ -785,12 +807,22 @@ static void card_update_proc(Layer *layer, GContext *ctx) { y += show_status_pill ? 26 : 8; if (s_row_count <= 0) { - graphics_context_set_fill_color(ctx, color_card_highlight()); - graphics_fill_circle(ctx, GPoint(content_x + (content_w / 2), y + 26), 15); - graphics_context_set_fill_color(ctx, color_background()); - graphics_fill_circle(ctx, GPoint(content_x + (content_w / 2), y + 26), 9); - draw_text(ctx, initial_loading ? "Loading quotas" : "Quota unavailable", GRect(content_x, y + 48, content_w, 34), - fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD), color_text_primary(), GTextAlignmentCenter); + bool setup_required = s_unloaded_state == QUOTA_UNLOADED_SETUP_REQUIRED; + if (setup_required) { + draw_setup_phone(ctx, GPoint(content_x + (content_w / 2), y + 26)); + } else { + graphics_context_set_fill_color(ctx, color_card_highlight()); + graphics_fill_circle(ctx, GPoint(content_x + (content_w / 2), y + 26), 15); + graphics_context_set_fill_color(ctx, color_background()); + graphics_fill_circle(ctx, GPoint(content_x + (content_w / 2), y + 26), 9); + } + const char *heading = "Quota unavailable"; + if (setup_required) + heading = "Setup needed"; + else if (initial_loading) + 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); } else { @@ -909,8 +941,19 @@ static void inbox_received_callback(DictionaryIterator *iterator, void *context) s_page_index = 0; s_page_count = 0; snprintf(s_title_text, sizeof(s_title_text), "Pebblexus"); - snprintf(s_reset_text, sizeof(s_reset_text), "%s", error_tuple->value->cstring); - snprintf(s_status_text, sizeof(s_status_text), "Select retries"); + Tuple *error_kind_tuple = dict_find(iterator, MESSAGE_KEY_ERROR_KIND); + QuotaErrorKind error_kind = QUOTA_ERROR_RETRYABLE; + if (error_kind_tuple != NULL && error_kind_tuple->value->int32 == QUOTA_ERROR_SETUP_REQUIRED) + 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"); + 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_status_text, sizeof(s_status_text), "Select retries"); + } s_updated_text[0] = '\0'; snap_motion_idle(); mark_card_dirty(); diff --git a/src/pkjs/index.js b/src/pkjs/index.js index 10391e8..bbfcbed 100644 --- a/src/pkjs/index.js +++ b/src/pkjs/index.js @@ -3,6 +3,8 @@ var createConfiguration = require("./config"); var CONFIG_BASE_URL = "plexusBaseUrl"; var CONFIG_ADMIN_KEY = "plexusAdminKey"; +var ERROR_KIND_RETRYABLE = 0; +var ERROR_KIND_SETUP_REQUIRED = 1; var clay = new Clay( createConfiguration( localStorage.getItem(CONFIG_BASE_URL) || "", @@ -51,8 +53,11 @@ function trimSlash(value) { return String(value || "").replace(/\/+$/, ""); } -function sendError(message) { - Pebble.sendAppMessage({ ERROR: String(message).slice(0, 38) }); +function sendError(kind, message) { + Pebble.sendAppMessage({ + ERROR: String(message).slice(0, 38), + ERROR_KIND: kind + }); } function xhrRequest(url, adminKey, callback) { @@ -434,7 +439,7 @@ function resetTextForPage(page) { function sendGroupPage(pageIndex) { if (quotaGroups.length === 0) { - sendError("No quota groups found"); + sendError(ERROR_KIND_RETRYABLE, "No quota groups found"); return; } @@ -515,14 +520,14 @@ function refreshQuotaWithDisplayNames( adminKey, function (error, responseText) { if (error) { - sendError(error.message); + sendError(ERROR_KIND_RETRYABLE, error.message); return; } try { quotaGroups = paginateGroups(flattenGroups(responseText, displayNames)); sendGroupPage(pageIndex); } catch (e) { - sendError("Bad quota response"); + sendError(ERROR_KIND_RETRYABLE, "Bad quota response"); } } ); @@ -532,7 +537,7 @@ function refreshQuota(pageIndex) { var baseUrl = trimSlash(localStorage.getItem(CONFIG_BASE_URL)); var adminKey = localStorage.getItem(CONFIG_ADMIN_KEY); if (!baseUrl || !adminKey) { - sendError("Set URL and admin key"); + sendError(ERROR_KIND_SETUP_REQUIRED, "Set URL and admin key"); return; } @@ -604,6 +609,6 @@ Pebble.addEventListener("webviewclosed", function (e) { }); refreshQuota(); } catch (error) { - sendError("Config save failed"); + sendError(ERROR_KIND_RETRYABLE, "Config save failed"); } }); diff --git a/test/pkjs.test.js b/test/pkjs.test.js index 28f4ba5..64187cf 100644 --- a/test/pkjs.test.js +++ b/test/pkjs.test.js @@ -8,6 +8,8 @@ var vm = require("node:vm"); var CONFIG_BASE_URL = "plexusBaseUrl"; var CONFIG_ADMIN_KEY = "plexusAdminKey"; +var ERROR_KIND_RETRYABLE = 0; +var ERROR_KIND_SETUP_REQUIRED = 1; var CLAY_URL = "data:text/html;charset=utf-8,clay"; var INDEX_PATH = path.join(__dirname, "..", "src", "pkjs", "index.js"); var CONFIG_PATH = path.join(__dirname, "..", "src", "pkjs", "config.js"); @@ -207,13 +209,49 @@ test("invalid configuration responses report an error without leaking values", f assert.deepEqual(kit.requests, []); assert.deepEqual(JSON.parse(JSON.stringify(kit.sentMessages)), [ - { ERROR: "Config save failed" } + { ERROR: "Config save failed", ERROR_KIND: ERROR_KIND_RETRYABLE } + ]); +}); + +test("missing credentials report that phone setup is required", function () { + var kit = loadPebbleKit(); + + kit.listeners.appmessage({ + payload: { REQUEST_QUOTA: 1, PAGE_INDEX: 0 } + }); + + assert.deepEqual(kit.requests, []); + assert.deepEqual(JSON.parse(JSON.stringify(kit.sentMessages)), [ + { + ERROR: "Set URL and admin key", + ERROR_KIND: ERROR_KIND_SETUP_REQUIRED + } + ]); +}); + +test("network failures remain retryable", function () { + var kit = loadPebbleKit({ + plexusBaseUrl: "https://plexus.example", + plexusAdminKey: "secret" + }); + + kit.listeners.appmessage({ + payload: { REQUEST_QUOTA: 1, PAGE_INDEX: 0 } + }); + assert.equal(kit.requests.length, 1); + kit.requests[0].onerror(); + assert.equal(kit.requests.length, 2); + kit.requests[1].onerror(); + + assert.deepEqual(JSON.parse(JSON.stringify(kit.sentMessages)), [ + { ERROR: "Network error", ERROR_KIND: ERROR_KIND_RETRYABLE } ]); }); test("phone-only configuration keys are not AppMessage keys", function () { var packageJson = require(path.join(__dirname, "..", "package.json")); + assert.equal(packageJson.pebble.messageKeys.includes("ERROR_KIND"), true); assert.equal(packageJson.pebble.messageKeys.includes(CONFIG_BASE_URL), false); assert.equal( packageJson.pebble.messageKeys.includes(CONFIG_ADMIN_KEY), -- knotmirror 0.0.1