diff --git a/src/c/main.c b/src/c/main.c index bbf0d83f40a17c0fc43790143a452ecd61a9b3db..91f94e491d7c656f5da084530ea6317b201c1aa9 100644 --- a/src/c/main.c +++ b/src/c/main.c @@ -2,7 +2,7 @@ #define MAX_ROWS 4 #define ANIMATION_INTERVAL_MS 33 -#define REFRESH_DOT_FRAMES 12 +#define REFRESH_SWEEP_FRAMES 20 #define SLIDE_OUT_FRAMES 4 #define SLIDE_IN_FRAMES 5 #define SLIDE_OUT_DISTANCE_PX 18 @@ -12,6 +12,10 @@ #define BOUNCE_FRAMES 3 #define BOUNCE_DISTANCE_PX 5 #define STAMP_FRAMES 4 +#define PRESS_KICK_FRAMES 5 +#define PRESS_KICK_DISTANCE_PX 12 +#define PRESS_SHAKE_FRAMES 6 +#define PRESS_SHAKE_DISTANCE_PX 12 // Page navigation choreography: the card content slides out in the travel // direction while we wait for the phone, the new card slides in from the @@ -27,6 +31,19 @@ typedef enum { MOTION_BARS, } MotionPhase; +// Refresh feedback rides the horizontal axis, which paging never uses, so the +// select button (middle-right of the body) feels like a plunger you pump data +// out of the phone with. KICK is the press stroke: the card content jolts left, +// away from the finger, then springs back. SHAKE is the failure recoil: a +// decaying side-to-side shudder, readable without reading the status text. Both +// run as a side-channel alongside MotionPhase, like the in-flight dots, so they +// compose with paging and the bar cascade instead of replacing them. +typedef enum { + PRESS_NONE, + PRESS_KICK, + PRESS_SHAKE, +} PressPhase; + typedef struct { char label[24]; char detail[28]; @@ -52,6 +69,9 @@ static int s_bar_frame = BAR_FILL_FRAMES + BAR_STAGGER_FRAMES * (MAX_ROWS - 1); static int s_refresh_frame = 0; static bool s_is_refreshing = false; static bool s_wrap_pending = false; +static PressPhase s_press_phase = PRESS_NONE; +static int s_press_frame = 0; +static int s_press_offset_x = 0; static char s_title_text[40] = "Pebblexus"; static char s_group_text[40] = ""; @@ -291,8 +311,29 @@ static void motion_timer_callback(void *data) { break; } + if (s_press_phase == PRESS_KICK) { + s_press_frame += 1; + if (s_press_frame >= PRESS_KICK_FRAMES) { + s_press_offset_x = 0; + s_press_phase = PRESS_NONE; + } else { + s_press_offset_x = ease_quad_out(-PRESS_KICK_DISTANCE_PX, 0, s_press_frame, PRESS_KICK_FRAMES); + needs_tick = true; + } + } else if (s_press_phase == PRESS_SHAKE) { + s_press_frame += 1; + if (s_press_frame >= PRESS_SHAKE_FRAMES) { + s_press_offset_x = 0; + s_press_phase = PRESS_NONE; + } else { + int amplitude = (PRESS_SHAKE_DISTANCE_PX * (PRESS_SHAKE_FRAMES - s_press_frame)) / PRESS_SHAKE_FRAMES; + s_press_offset_x = (s_press_frame % 2 == 0) ? amplitude : -amplitude; + needs_tick = true; + } + } + if (s_is_refreshing) { - s_refresh_frame = (s_refresh_frame + 1) % REFRESH_DOT_FRAMES; + s_refresh_frame = (s_refresh_frame + 1) % REFRESH_SWEEP_FRAMES; needs_tick = true; } @@ -343,10 +384,39 @@ static void start_slide_back(void) { ensure_motion_timer(); } +// The select press lands instantly: the card jolts left on this frame, then the +// timer springs it back. The pump stroke that starts a refresh. +static void start_press_kick(void) { + s_press_phase = PRESS_KICK; + s_press_frame = 0; + s_press_offset_x = -PRESS_KICK_DISTANCE_PX; + mark_card_dirty(); + ensure_motion_timer(); +} + +// The pump came up empty: the card recoils side to side and settles. Failure +// you can feel without reading "No phone" or "Dropped". +static void start_press_shake(void) { + s_press_phase = PRESS_SHAKE; + s_press_frame = 0; + s_press_offset_x = PRESS_SHAKE_DISTANCE_PX; + mark_card_dirty(); + ensure_motion_timer(); +} + +// Arrival supersedes the press stroke so the bars cascade from rest, letting the +// press flow into the reward as one gesture. +static void clear_press_feedback(void) { + s_press_phase = PRESS_NONE; + s_press_frame = 0; + s_press_offset_x = 0; +} + // Data arrived in place (initial load or select refresh): cascade the bars only. static void start_bar_cascade(void) { s_wrap_pending = false; s_slide_offset = 0; + clear_press_feedback(); s_motion_phase = MOTION_BARS; s_motion_frame = 0; s_bar_frame = 0; @@ -356,6 +426,7 @@ static void start_bar_cascade(void) { static void snap_motion_idle(void) { s_wrap_pending = false; + clear_press_feedback(); s_motion_phase = MOTION_IDLE; s_motion_frame = 0; s_slide_offset = 0; @@ -368,7 +439,7 @@ static void request_quota(void) { if (result != APP_MSG_OK || iter == NULL) { snprintf(s_status_text, sizeof(s_status_text), "Send fail"); s_is_refreshing = false; - mark_card_dirty(); + start_press_shake(); return; } @@ -380,12 +451,11 @@ static void request_quota(void) { s_updated_text[0] = '\0'; s_is_refreshing = true; s_refresh_frame = 0; - mark_card_dirty(); - ensure_motion_timer(); + start_press_kick(); } else { snprintf(s_status_text, sizeof(s_status_text), "Send fail"); s_is_refreshing = false; - mark_card_dirty(); + start_press_shake(); } } @@ -471,17 +541,25 @@ 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_refresh_dots(GContext *ctx, GRect bounds) { - if (!s_is_refreshing) - return; - int phase = s_refresh_frame; - int center_x = bounds.size.w - 23; - int center_y = 18; +// 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 +// the wait reads as the phone being pumped, not a generic spinner. Shares the +// corner with the page count by mutual exclusion, so the two never overlap. +static void draw_refresh_pump(GContext *ctx, GRect box) { + const int track_height = 6; + const int segment_width = 14; + int track_y = box.origin.y + (box.size.h - track_height) / 2; - graphics_context_set_fill_color(ctx, color_text_muted()); - graphics_fill_circle(ctx, GPoint(center_x - 7, center_y), phase < 4 ? 3 : 2); - graphics_fill_circle(ctx, GPoint(center_x, center_y), phase >= 3 && phase < 7 ? 3 : 2); - graphics_fill_circle(ctx, GPoint(center_x + 7, center_y), phase >= 6 ? 3 : 2); + graphics_context_set_fill_color(ctx, color_track()); + graphics_fill_rect(ctx, GRect(box.origin.x, track_y, box.size.w, track_height), 3, GCornersAll); + + int travel = box.size.w - segment_width; + int half = REFRESH_SWEEP_FRAMES / 2; + int segment_x = s_refresh_frame < half ? ease_quad_out(0, travel, s_refresh_frame, half) + : ease_quad_out(travel, 0, s_refresh_frame - half, half); + graphics_context_set_fill_color(ctx, color_text_secondary()); + graphics_fill_rect(ctx, GRect(box.origin.x + segment_x, track_y, segment_width, track_height), 3, GCornersAll); } // Vertical rail along the right edge: pages travel up/down, so does the rail. @@ -513,7 +591,7 @@ static void card_update_proc(Layer *layer, GContext *ctx) { const int margin = 10; int offset = current_slide_offset(); - int content_x = margin; + int content_x = margin + s_press_offset_x; int content_w = bounds.size.w - (margin * 2); int y = 8 + offset; @@ -536,9 +614,13 @@ static void card_update_proc(Layer *layer, GContext *ctx) { } else { snprintf(page_text, sizeof(page_text), "--"); } - draw_text(ctx, page_text, GRect(content_x + content_w - 46, y + 2, 46, 24), - fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), color_text_secondary(), GTextAlignmentRight); - draw_refresh_dots(ctx, bounds); + GRect status_corner = GRect(content_x + content_w - 46, y + 2, 46, 24); + if (s_is_refreshing) { + draw_refresh_pump(ctx, status_corner); + } else { + draw_text(ctx, page_text, status_corner, fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), color_text_secondary(), + GTextAlignmentRight); + } y += 26; if (s_status_text[0] != '\0') { @@ -724,16 +806,22 @@ static void inbox_received_callback(DictionaryIterator *iterator, void *context) } static void inbox_dropped_callback(AppMessageResult reason, void *context) { + bool was_refreshing = s_is_refreshing; s_is_refreshing = false; snprintf(s_status_text, sizeof(s_status_text), "Dropped"); start_slide_back(); + if (was_refreshing) + start_press_shake(); mark_card_dirty(); } static void outbox_failed_callback(DictionaryIterator *iterator, AppMessageResult reason, void *context) { + bool was_refreshing = s_is_refreshing; s_is_refreshing = false; snprintf(s_status_text, sizeof(s_status_text), "No phone"); start_slide_back(); + if (was_refreshing) + start_press_shake(); mark_card_dirty(); }