@@ -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();
}