Juice refresh with Opus

Amolith created

Change summary

src/c/main.c | 128 +++++++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 108 insertions(+), 20 deletions(-)

Detailed changes

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