main.c

  1#include <pebble.h>
  2
  3#define MAX_ROWS 4
  4#define ANIMATION_INTERVAL_MS 33
  5#define REFRESH_SWEEP_FRAMES 20
  6#define SLIDE_OUT_FRAMES 4
  7#define SLIDE_IN_FRAMES 5
  8#define SLIDE_OUT_DISTANCE_PX 18
  9#define SLIDE_IN_DISTANCE_PX 14
 10#define BAR_FILL_FRAMES 10
 11#define BAR_STAGGER_FRAMES 3
 12#define BOUNCE_FRAMES 3
 13#define BOUNCE_DISTANCE_PX 5
 14#define STAMP_FRAMES 4
 15#define PRESS_KICK_FRAMES 5
 16#define PRESS_KICK_DISTANCE_PX 12
 17#define PRESS_SHAKE_FRAMES 6
 18#define PRESS_SHAKE_DISTANCE_PX 12
 19
 20// Page navigation choreography: the card content slides out in the travel
 21// direction while we wait for the phone, the new card slides in from the
 22// opposite side, then the quota bars pour in top-down. On failure the card
 23// slides back into place. When the page order wraps around, the incoming
 24// card overshoots and rubber-bands back to signal the seam in the loop.
 25typedef enum {
 26  MOTION_IDLE,
 27  MOTION_SLIDE_OUT,
 28  MOTION_WAITING,
 29  MOTION_SLIDE_IN,
 30  MOTION_BOUNCE,
 31  MOTION_BARS,
 32} MotionPhase;
 33
 34// Refresh feedback rides the horizontal axis, which paging never uses, so the
 35// select button (middle-right of the body) feels like a plunger you pump data
 36// out of the phone with. KICK is the press stroke: the card content jolts left,
 37// away from the finger, then springs back. SHAKE is the failure recoil: a
 38// decaying side-to-side shudder, readable without reading the status text. Both
 39// run as a side-channel alongside MotionPhase, like the in-flight dots, so they
 40// compose with paging and the bar cascade instead of replacing them.
 41typedef enum {
 42  PRESS_NONE,
 43  PRESS_KICK,
 44  PRESS_SHAKE,
 45} PressPhase;
 46
 47typedef struct {
 48  char label[24];
 49  char detail[28];
 50  int percent;
 51  int status_rank;
 52  bool has_bar;
 53} QuotaRow;
 54
 55static Window *s_main_window;
 56static Layer *s_card_layer;
 57static AppTimer *s_animation_timer;
 58
 59static int s_page_index = 0;
 60static int s_page_count = 0;
 61static int s_row_count = 0;
 62static MotionPhase s_motion_phase = MOTION_IDLE;
 63static int s_motion_frame = 0;
 64static int s_slide_offset = 0;
 65static int s_slide_from = 0;
 66static int s_slide_to = 0;
 67static int s_slide_direction = 1;
 68static int s_bar_frame = BAR_FILL_FRAMES + BAR_STAGGER_FRAMES * (MAX_ROWS - 1);
 69static int s_refresh_frame = 0;
 70static bool s_is_refreshing = false;
 71static bool s_wrap_pending = false;
 72static PressPhase s_press_phase = PRESS_NONE;
 73static int s_press_frame = 0;
 74static int s_press_offset_x = 0;
 75
 76static char s_title_text[40] = "Pebblexus";
 77static char s_group_text[40] = "";
 78static char s_reset_text[40] = "Set URL and admin key";
 79static char s_status_text[16] = "Waiting";
 80static char s_updated_text[16] = "";
 81static QuotaRow s_rows[MAX_ROWS];
 82
 83static uint32_t row_label_key(int row_index) {
 84  switch (row_index) {
 85  case 0:
 86    return MESSAGE_KEY_ROW_0_LABEL;
 87  case 1:
 88    return MESSAGE_KEY_ROW_1_LABEL;
 89  case 2:
 90    return MESSAGE_KEY_ROW_2_LABEL;
 91  default:
 92    return MESSAGE_KEY_ROW_3_LABEL;
 93  }
 94}
 95
 96static uint32_t row_detail_key(int row_index) {
 97  switch (row_index) {
 98  case 0:
 99    return MESSAGE_KEY_ROW_0_DETAIL;
100  case 1:
101    return MESSAGE_KEY_ROW_1_DETAIL;
102  case 2:
103    return MESSAGE_KEY_ROW_2_DETAIL;
104  default:
105    return MESSAGE_KEY_ROW_3_DETAIL;
106  }
107}
108
109static uint32_t row_percent_key(int row_index) {
110  switch (row_index) {
111  case 0:
112    return MESSAGE_KEY_ROW_0_PERCENT;
113  case 1:
114    return MESSAGE_KEY_ROW_1_PERCENT;
115  case 2:
116    return MESSAGE_KEY_ROW_2_PERCENT;
117  default:
118    return MESSAGE_KEY_ROW_3_PERCENT;
119  }
120}
121
122static uint32_t row_status_key(int row_index) {
123  switch (row_index) {
124  case 0:
125    return MESSAGE_KEY_ROW_0_STATUS;
126  case 1:
127    return MESSAGE_KEY_ROW_1_STATUS;
128  case 2:
129    return MESSAGE_KEY_ROW_2_STATUS;
130  default:
131    return MESSAGE_KEY_ROW_3_STATUS;
132  }
133}
134
135static uint32_t row_has_bar_key(int row_index) {
136  switch (row_index) {
137  case 0:
138    return MESSAGE_KEY_ROW_0_HAS_BAR;
139  case 1:
140    return MESSAGE_KEY_ROW_1_HAS_BAR;
141  case 2:
142    return MESSAGE_KEY_ROW_2_HAS_BAR;
143  default:
144    return MESSAGE_KEY_ROW_3_HAS_BAR;
145  }
146}
147
148static int clamp_percent(int percent) {
149  if (percent < 0)
150    return 0;
151  if (percent > 100)
152    return 100;
153  return percent;
154}
155
156static GColor color_background(void) { return PBL_IF_COLOR_ELSE(GColorWhite, GColorWhite); }
157
158static GColor color_card_outline(void) { return PBL_IF_COLOR_ELSE(GColorFromHEX(0xAAFFFF), GColorBlack); }
159
160static GColor color_text_primary(void) { return GColorBlack; }
161
162static GColor color_text_secondary(void) { return PBL_IF_COLOR_ELSE(GColorCobaltBlue, GColorBlack); }
163
164static GColor color_text_muted(void) { return PBL_IF_COLOR_ELSE(GColorDarkGray, GColorBlack); }
165
166static GColor color_text_soft(void) { return PBL_IF_COLOR_ELSE(GColorDukeBlue, GColorBlack); }
167
168static GColor color_track(void) { return PBL_IF_COLOR_ELSE(GColorLightGray, GColorBlack); }
169
170static GColor color_card_highlight(void) { return PBL_IF_COLOR_ELSE(GColorVividCerulean, GColorBlack); }
171
172static GColor color_warning_soft(void) { return PBL_IF_COLOR_ELSE(GColorIcterine, GColorWhite); }
173
174static GColor color_ok_soft(void) { return PBL_IF_COLOR_ELSE(GColorMintGreen, GColorWhite); }
175
176static GColor color_for_status(int status_rank) {
177#ifdef PBL_COLOR
178  if (status_rank >= 4)
179    return GColorRed;
180  if (status_rank >= 3)
181    return GColorOrange;
182  return GColorJaegerGreen;
183#else
184  return GColorBlack;
185#endif
186}
187
188static GColor color_status_fill(void) {
189  if (strcmp(s_status_text, "CRITICAL") == 0)
190    return PBL_IF_COLOR_ELSE(GColorMelon, GColorWhite);
191  if (strcmp(s_status_text, "WARNING") == 0)
192    return color_warning_soft();
193  return color_ok_soft();
194}
195
196static GColor color_for_status_text(void) {
197  if (strcmp(s_status_text, "CRITICAL") == 0)
198    return color_for_status(4);
199  if (strcmp(s_status_text, "WARNING") == 0)
200    return color_for_status(3);
201  return color_for_status(1);
202}
203
204static int ease_quad_in(int from, int to, int frame, int frame_count) {
205  if (frame <= 0)
206    return from;
207  if (frame >= frame_count)
208    return to;
209  int t = (frame * 100) / frame_count;
210  return from + ((to - from) * t * t) / 10000;
211}
212
213static int ease_quad_out(int from, int to, int frame, int frame_count) {
214  if (frame <= 0)
215    return from;
216  if (frame >= frame_count)
217    return to;
218  int t = (frame * 100) / frame_count;
219  return from - ((to - from) * t * (t - 200)) / 10000;
220}
221
222static int current_slide_offset(void) { return s_slide_offset; }
223
224static int bar_cascade_total_frames(void) {
225  int rows = s_row_count > MAX_ROWS ? MAX_ROWS : s_row_count;
226  if (rows < 1)
227    rows = 1;
228  int cascade_frames = BAR_FILL_FRAMES + BAR_STAGGER_FRAMES * (rows - 1);
229  int stamp_end_frame = BAR_FILL_FRAMES + STAMP_FRAMES;
230  return cascade_frames > stamp_end_frame ? cascade_frames : stamp_end_frame;
231}
232
233static int current_bar_percent(int row_index, int percent) {
234  int clamped = clamp_percent(percent);
235  if (s_motion_phase == MOTION_SLIDE_IN || s_motion_phase == MOTION_BOUNCE)
236    return 0;
237  if (s_motion_phase != MOTION_BARS)
238    return clamped;
239  int frame = s_bar_frame - (row_index * BAR_STAGGER_FRAMES);
240  return ease_quad_out(0, clamped, frame, BAR_FILL_FRAMES);
241}
242
243static bool status_is_alarming(void) {
244  return strcmp(s_status_text, "CRITICAL") == 0 || strcmp(s_status_text, "WARNING") == 0;
245}
246
247// The status pill stamps in as the worst quota bar lands: briefly oversized,
248// then settled. Only when the news warrants it.
249static int status_pill_inflation(void) {
250  if (s_motion_phase != MOTION_BARS || !status_is_alarming())
251    return 0;
252  int stamp_frame = s_bar_frame - BAR_FILL_FRAMES;
253  if (stamp_frame < 0 || stamp_frame >= STAMP_FRAMES)
254    return 0;
255  return stamp_frame < STAMP_FRAMES / 2 ? 2 : 1;
256}
257
258static void mark_card_dirty(void) {
259  if (s_card_layer != NULL)
260    layer_mark_dirty(s_card_layer);
261}
262
263static void motion_timer_callback(void *data) {
264  s_animation_timer = NULL;
265  bool needs_tick = false;
266
267  switch (s_motion_phase) {
268  case MOTION_SLIDE_OUT:
269    s_motion_frame += 1;
270    s_slide_offset = ease_quad_in(s_slide_from, s_slide_to, s_motion_frame, SLIDE_OUT_FRAMES);
271    if (s_motion_frame >= SLIDE_OUT_FRAMES) {
272      s_motion_phase = MOTION_WAITING;
273    } else {
274      needs_tick = true;
275    }
276    break;
277  case MOTION_SLIDE_IN:
278    s_motion_frame += 1;
279    s_slide_offset = ease_quad_out(s_slide_from, s_slide_to, s_motion_frame, SLIDE_IN_FRAMES);
280    if (s_motion_frame >= SLIDE_IN_FRAMES) {
281      if (s_slide_to != 0) {
282        s_motion_phase = MOTION_BOUNCE;
283        s_motion_frame = 0;
284        s_slide_from = s_slide_to;
285        s_slide_to = 0;
286      } else {
287        s_motion_phase = MOTION_BARS;
288        s_bar_frame = 0;
289      }
290    }
291    needs_tick = true;
292    break;
293  case MOTION_BOUNCE:
294    s_motion_frame += 1;
295    s_slide_offset = ease_quad_out(s_slide_from, 0, s_motion_frame, BOUNCE_FRAMES);
296    if (s_motion_frame >= BOUNCE_FRAMES) {
297      s_motion_phase = MOTION_BARS;
298      s_bar_frame = 0;
299    }
300    needs_tick = true;
301    break;
302  case MOTION_BARS:
303    s_bar_frame += 1;
304    if (s_bar_frame >= bar_cascade_total_frames()) {
305      s_motion_phase = MOTION_IDLE;
306    } else {
307      needs_tick = true;
308    }
309    break;
310  default:
311    break;
312  }
313
314  if (s_press_phase == PRESS_KICK) {
315    s_press_frame += 1;
316    if (s_press_frame >= PRESS_KICK_FRAMES) {
317      s_press_offset_x = 0;
318      s_press_phase = PRESS_NONE;
319    } else {
320      s_press_offset_x = ease_quad_out(-PRESS_KICK_DISTANCE_PX, 0, s_press_frame, PRESS_KICK_FRAMES);
321      needs_tick = true;
322    }
323  } else if (s_press_phase == PRESS_SHAKE) {
324    s_press_frame += 1;
325    if (s_press_frame >= PRESS_SHAKE_FRAMES) {
326      s_press_offset_x = 0;
327      s_press_phase = PRESS_NONE;
328    } else {
329      int amplitude = (PRESS_SHAKE_DISTANCE_PX * (PRESS_SHAKE_FRAMES - s_press_frame)) / PRESS_SHAKE_FRAMES;
330      s_press_offset_x = (s_press_frame % 2 == 0) ? amplitude : -amplitude;
331      needs_tick = true;
332    }
333  }
334
335  if (s_is_refreshing) {
336    s_refresh_frame = (s_refresh_frame + 1) % REFRESH_SWEEP_FRAMES;
337    needs_tick = true;
338  }
339
340  mark_card_dirty();
341  if (needs_tick)
342    s_animation_timer = app_timer_register(ANIMATION_INTERVAL_MS, motion_timer_callback, NULL);
343}
344
345static void ensure_motion_timer(void) {
346  if (s_animation_timer == NULL)
347    s_animation_timer = app_timer_register(ANIMATION_INTERVAL_MS, motion_timer_callback, NULL);
348}
349
350// Card departs in the travel direction and holds offset until the phone replies.
351static void start_slide_out(int direction) {
352  s_slide_direction = direction < 0 ? -1 : 1;
353  s_slide_from = s_slide_offset;
354  s_slide_to = -s_slide_direction * SLIDE_OUT_DISTANCE_PX;
355  s_motion_phase = MOTION_SLIDE_OUT;
356  s_motion_frame = 0;
357  mark_card_dirty();
358  ensure_motion_timer();
359}
360
361// New card arrives from the opposite side, settles, then bars pour in. On a
362// wraparound it overshoots past its resting position and rubber-bands back.
363static void start_slide_in(void) {
364  s_slide_from = s_slide_direction * SLIDE_IN_DISTANCE_PX;
365  s_slide_to = s_wrap_pending ? -s_slide_direction * BOUNCE_DISTANCE_PX : 0;
366  s_wrap_pending = false;
367  s_slide_offset = s_slide_from;
368  s_motion_phase = MOTION_SLIDE_IN;
369  s_motion_frame = 0;
370  mark_card_dirty();
371  ensure_motion_timer();
372}
373
374// The page never arrived: the card slides back into place.
375static void start_slide_back(void) {
376  s_wrap_pending = false;
377  if (s_motion_phase != MOTION_SLIDE_OUT && s_motion_phase != MOTION_WAITING)
378    return;
379  s_slide_from = s_slide_offset;
380  s_slide_to = 0;
381  s_motion_phase = MOTION_SLIDE_IN;
382  s_motion_frame = 0;
383  mark_card_dirty();
384  ensure_motion_timer();
385}
386
387// The select press lands instantly: the card jolts left on this frame, then the
388// timer springs it back. The pump stroke that starts a refresh.
389static void start_press_kick(void) {
390  s_press_phase = PRESS_KICK;
391  s_press_frame = 0;
392  s_press_offset_x = -PRESS_KICK_DISTANCE_PX;
393  mark_card_dirty();
394  ensure_motion_timer();
395}
396
397// The pump came up empty: the card recoils side to side and settles. Failure
398// you can feel without reading "No phone" or "Dropped".
399static void start_press_shake(void) {
400  s_press_phase = PRESS_SHAKE;
401  s_press_frame = 0;
402  s_press_offset_x = PRESS_SHAKE_DISTANCE_PX;
403  mark_card_dirty();
404  ensure_motion_timer();
405}
406
407// Arrival supersedes the press stroke so the bars cascade from rest, letting the
408// press flow into the reward as one gesture.
409static void clear_press_feedback(void) {
410  s_press_phase = PRESS_NONE;
411  s_press_frame = 0;
412  s_press_offset_x = 0;
413}
414
415// Data arrived in place (initial load or select refresh): cascade the bars only.
416static void start_bar_cascade(void) {
417  s_wrap_pending = false;
418  s_slide_offset = 0;
419  clear_press_feedback();
420  s_motion_phase = MOTION_BARS;
421  s_motion_frame = 0;
422  s_bar_frame = 0;
423  mark_card_dirty();
424  ensure_motion_timer();
425}
426
427static void snap_motion_idle(void) {
428  s_wrap_pending = false;
429  clear_press_feedback();
430  s_motion_phase = MOTION_IDLE;
431  s_motion_frame = 0;
432  s_slide_offset = 0;
433  s_bar_frame = bar_cascade_total_frames();
434}
435
436static void request_quota(void) {
437  DictionaryIterator *iter;
438  AppMessageResult result = app_message_outbox_begin(&iter);
439  if (result != APP_MSG_OK || iter == NULL) {
440    snprintf(s_status_text, sizeof(s_status_text), "Send fail");
441    s_is_refreshing = false;
442    start_press_shake();
443    return;
444  }
445
446  dict_write_uint8(iter, MESSAGE_KEY_REQUEST_QUOTA, 1);
447  dict_write_int32(iter, MESSAGE_KEY_PAGE_INDEX, s_page_index);
448  result = app_message_outbox_send();
449  if (result == APP_MSG_OK) {
450    snprintf(s_status_text, sizeof(s_status_text), "Refreshing");
451    s_updated_text[0] = '\0';
452    s_is_refreshing = true;
453    s_refresh_frame = 0;
454    start_press_kick();
455  } else {
456    snprintf(s_status_text, sizeof(s_status_text), "Send fail");
457    s_is_refreshing = false;
458    start_press_shake();
459  }
460}
461
462static void request_page(int page_index) {
463  if (s_page_count <= 0)
464    return;
465  int direction = page_index >= s_page_index ? 1 : -1;
466  bool wrapped = false;
467  if (page_index < 0) {
468    page_index = s_page_count - 1;
469    direction = -1;
470    wrapped = true;
471  }
472  if (page_index >= s_page_count) {
473    page_index = 0;
474    direction = 1;
475    wrapped = true;
476  }
477
478  DictionaryIterator *iter;
479  AppMessageResult result = app_message_outbox_begin(&iter);
480  if (result != APP_MSG_OK || iter == NULL) {
481    snprintf(s_status_text, sizeof(s_status_text), "Page fail");
482    mark_card_dirty();
483    return;
484  }
485
486  s_page_index = page_index;
487  dict_write_int32(iter, MESSAGE_KEY_REQUEST_PAGE, page_index);
488  result = app_message_outbox_send();
489  if (result == APP_MSG_OK) {
490    s_wrap_pending = wrapped;
491    start_slide_out(direction);
492  } else {
493    snprintf(s_status_text, sizeof(s_status_text), "Send fail");
494    mark_card_dirty();
495  }
496}
497
498static void up_click_handler(ClickRecognizerRef recognizer, void *context) { request_page(s_page_index - 1); }
499
500static void down_click_handler(ClickRecognizerRef recognizer, void *context) { request_page(s_page_index + 1); }
501
502static void select_click_handler(ClickRecognizerRef recognizer, void *context) { request_quota(); }
503
504static void click_config_provider(void *context) {
505  window_single_repeating_click_subscribe(BUTTON_ID_UP, 180, up_click_handler);
506  window_single_click_subscribe(BUTTON_ID_SELECT, select_click_handler);
507  window_single_repeating_click_subscribe(BUTTON_ID_DOWN, 180, down_click_handler);
508}
509
510static void draw_text(GContext *ctx, const char *text, GRect box, GFont font, GColor color, GTextAlignment alignment) {
511  graphics_context_set_text_color(ctx, color);
512  graphics_draw_text(ctx, text, font, box, GTextOverflowModeTrailingEllipsis, alignment, NULL);
513}
514
515static int text_width(const char *text, GFont font) {
516  GSize size = graphics_text_layout_get_content_size(text, font, GRect(0, 0, 500, 30), GTextOverflowModeWordWrap,
517                                                     GTextAlignmentLeft);
518  return size.w;
519}
520
521static void draw_progress_bar(GContext *ctx, GRect frame, int row_index, int percent, GColor color) {
522  graphics_context_set_fill_color(ctx, color_track());
523  graphics_fill_rect(ctx, frame, 3, GCornersAll);
524
525  int fill_width = (frame.size.w * current_bar_percent(row_index, percent)) / 100;
526  if (fill_width < 2 && percent > 0)
527    fill_width = 2;
528  graphics_context_set_fill_color(ctx, color);
529  graphics_fill_rect(ctx, GRect(frame.origin.x, frame.origin.y, fill_width, frame.size.h), 3, GCornersAll);
530}
531
532static void draw_status_pill(GContext *ctx, GRect frame) {
533  int inflation = status_pill_inflation();
534  GRect pill = GRect(frame.origin.x - inflation, frame.origin.y - inflation, frame.size.w + (inflation * 2),
535                     frame.size.h + (inflation * 2));
536  graphics_context_set_fill_color(ctx, color_status_fill());
537  graphics_fill_rect(ctx, pill, 6, GCornersAll);
538  graphics_context_set_stroke_color(ctx, color_for_status_text());
539  graphics_draw_rect(ctx, pill);
540  draw_text(ctx, s_status_text, GRect(frame.origin.x, frame.origin.y - 4, frame.size.w, frame.size.h + 4),
541            fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), color_text_primary(), GTextAlignmentCenter);
542}
543
544// In-flight pump stroke: a short segment strokes back and forth along the
545// horizontal pump axis inside the status corner the page count usually owns.
546// It decelerates into each end like a piston hitting the end of its travel, so
547// the wait reads as the phone being pumped, not a generic spinner. Shares the
548// corner with the page count by mutual exclusion, so the two never overlap.
549static void draw_refresh_pump(GContext *ctx, GRect box) {
550  const int track_height = 6;
551  const int segment_width = 14;
552  int track_y = box.origin.y + (box.size.h - track_height) / 2;
553
554  graphics_context_set_fill_color(ctx, color_track());
555  graphics_fill_rect(ctx, GRect(box.origin.x, track_y, box.size.w, track_height), 3, GCornersAll);
556
557  int travel = box.size.w - segment_width;
558  int half = REFRESH_SWEEP_FRAMES / 2;
559  int segment_x = s_refresh_frame < half ? ease_quad_out(0, travel, s_refresh_frame, half)
560                                         : ease_quad_out(travel, 0, s_refresh_frame - half, half);
561  graphics_context_set_fill_color(ctx, color_text_secondary());
562  graphics_fill_rect(ctx, GRect(box.origin.x + segment_x, track_y, segment_width, track_height), 3, GCornersAll);
563}
564
565// Vertical rail along the right edge: pages travel up/down, so does the rail.
566static void draw_page_dots(GContext *ctx, GRect frame) {
567  if (s_page_count <= 1)
568    return;
569
570  int dots = s_page_count > 7 ? 7 : s_page_count;
571  int total_height = dots * 7 - 3;
572  int x = frame.origin.x + frame.size.w - 5;
573  int y = frame.origin.y + (frame.size.h - total_height) / 2;
574
575  for (int i = 0; i < dots; i += 1) {
576    int active = i == s_page_index || (s_page_count > 7 && i == 6 && s_page_index >= 6);
577    graphics_context_set_fill_color(ctx, active ? color_card_highlight() : color_text_muted());
578    int radius = active ? 2 : 1;
579    bool card_in_flight = s_motion_phase == MOTION_SLIDE_OUT || s_motion_phase == MOTION_WAITING ||
580                          s_motion_phase == MOTION_SLIDE_IN || s_motion_phase == MOTION_BOUNCE;
581    if (active && card_in_flight)
582      radius = 3;
583    graphics_fill_circle(ctx, GPoint(x, y + (i * 7)), radius);
584  }
585}
586
587static void card_update_proc(Layer *layer, GContext *ctx) {
588  GRect bounds = layer_get_bounds(layer);
589  graphics_context_set_fill_color(ctx, color_background());
590  graphics_fill_rect(ctx, bounds, 0, GCornerNone);
591
592  const int margin = 10;
593  int offset = current_slide_offset();
594  int content_x = margin + s_press_offset_x;
595  int content_w = bounds.size.w - (margin * 2);
596  int y = 8 + offset;
597
598  draw_text(ctx, s_title_text, GRect(content_x, y, content_w - 50, 29), fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD),
599            color_text_primary(), GTextAlignmentLeft);
600
601  char page_text[16];
602  if (s_page_count > 0) {
603    int display_page_index = s_page_index + 1;
604    if (display_page_index < 1)
605      display_page_index = 1;
606    if (display_page_index > 99)
607      display_page_index = 99;
608    int display_page_count = s_page_count;
609    if (display_page_count < 1)
610      display_page_count = 1;
611    if (display_page_count > 99)
612      display_page_count = 99;
613    snprintf(page_text, sizeof(page_text), "%d/%d", display_page_index, display_page_count);
614  } else {
615    snprintf(page_text, sizeof(page_text), "--");
616  }
617  GRect status_corner = GRect(content_x + content_w - 46, y + 2, 46, 24);
618  if (s_is_refreshing) {
619    draw_refresh_pump(ctx, status_corner);
620  } else {
621    draw_text(ctx, page_text, status_corner, fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), color_text_secondary(),
622              GTextAlignmentRight);
623  }
624
625  y += 26;
626  if (s_status_text[0] != '\0') {
627    draw_status_pill(ctx, GRect(content_x + content_w - 60, y + 1, 60, 18));
628  }
629
630  y += s_status_text[0] == '\0' ? 8 : 26;
631  if (s_row_count <= 0) {
632    graphics_context_set_fill_color(ctx, color_card_highlight());
633    graphics_fill_circle(ctx, GPoint(content_x + (content_w / 2), y + 26), 15);
634    graphics_context_set_fill_color(ctx, color_background());
635    graphics_fill_circle(ctx, GPoint(content_x + (content_w / 2), y + 26), 9);
636    draw_text(ctx, "Quota unavailable", GRect(content_x, y + 48, content_w, 34),
637              fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD), color_text_primary(), GTextAlignmentCenter);
638    draw_text(ctx, s_reset_text, GRect(content_x, y + 86, content_w, 46), fonts_get_system_font(FONT_KEY_GOTHIC_24),
639              color_text_secondary(), GTextAlignmentCenter);
640  } else {
641    // Row layout heights (42/28/50 + 3px separators) are mirrored by the
642    // phone-side page packing in src/pkjs/index.js; keep them in sync.
643    GFont row_font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD);
644    int row_bottom_limit = bounds.size.h - 34 + offset;
645
646    // One detail column for all bar rows keeps the bar tracks aligned.
647    int detail_column_w = 28;
648    for (int i = 0; i < s_row_count && i < MAX_ROWS; i += 1) {
649      if (!s_rows[i].has_bar)
650        continue;
651      int detail_w = text_width(s_rows[i].detail, row_font);
652      if (detail_w > detail_column_w)
653        detail_column_w = detail_w;
654    }
655    if (detail_column_w > 100)
656      detail_column_w = 100;
657
658    int rows_drawn = 0;
659    for (int i = 0; i < s_row_count && i < MAX_ROWS; i += 1) {
660      QuotaRow *row = &s_rows[i];
661      GColor accent = color_for_status(row->status_rank);
662      if (row->has_bar) {
663        if (y + 42 > row_bottom_limit)
664          break;
665        draw_text(ctx, row->label, GRect(content_x, y, content_w, 22), row_font, color_text_primary(),
666                  GTextAlignmentLeft);
667        y += 22;
668        int bar_w = content_w - detail_column_w - 8;
669        draw_text(ctx, row->detail, GRect(content_x + bar_w + 8, y - 3, detail_column_w, 22), row_font,
670                  color_text_soft(), GTextAlignmentRight);
671        draw_progress_bar(ctx, GRect(content_x, y + 6, bar_w, 6), i, row->percent, accent);
672        y += 20;
673      } else {
674        int detail_w = text_width(row->detail, row_font);
675        if (detail_w > 100)
676          detail_w = 100;
677        int label_w = content_w - detail_w - 8;
678        bool two_line = text_width(row->label, row_font) > label_w;
679        if (y + (two_line ? 50 : 28) > row_bottom_limit)
680          break;
681        if (two_line) {
682          draw_text(ctx, row->label, GRect(content_x, y, content_w, 22), row_font, color_text_primary(),
683                    GTextAlignmentLeft);
684          y += 22;
685          draw_text(ctx, row->detail, GRect(content_x, y, content_w, 22), row_font, color_text_primary(),
686                    GTextAlignmentRight);
687          y += 28;
688        } else {
689          draw_text(ctx, row->label, GRect(content_x, y, label_w, 22), row_font, color_text_primary(),
690                    GTextAlignmentLeft);
691          draw_text(ctx, row->detail, GRect(content_x + label_w + 8, y, detail_w, 22), row_font, color_text_primary(),
692                    GTextAlignmentRight);
693          y += 28;
694        }
695      }
696      rows_drawn += 1;
697      if (i + 1 < s_row_count && i + 1 < MAX_ROWS) {
698        graphics_context_set_stroke_color(ctx, color_card_outline());
699        graphics_draw_line(ctx, GPoint(content_x, y), GPoint(content_x + content_w, y));
700        y += 3;
701      }
702    }
703
704    int rows_visible = s_row_count > MAX_ROWS ? MAX_ROWS : s_row_count;
705    if (rows_drawn < rows_visible || s_row_count > MAX_ROWS) {
706      draw_text(ctx, "+ more", GRect(content_x, y, content_w, 22), fonts_get_system_font(FONT_KEY_GOTHIC_18),
707                color_text_muted(), GTextAlignmentCenter);
708    }
709  }
710
711  draw_page_dots(ctx, bounds);
712
713  draw_text(ctx, s_reset_text, GRect(content_x, bounds.size.h - 32 + offset, content_w, 24),
714            fonts_get_system_font(FONT_KEY_GOTHIC_18), color_text_muted(), GTextAlignmentCenter);
715}
716
717static void copy_string_tuple(DictionaryIterator *iterator, uint32_t key, char *buffer, size_t buffer_size) {
718  Tuple *tuple = dict_find(iterator, key);
719  if (tuple == NULL)
720    return;
721  snprintf(buffer, buffer_size, "%s", tuple->value->cstring);
722}
723
724static void read_row(DictionaryIterator *iterator, int row_index) {
725  QuotaRow *row = &s_rows[row_index];
726  copy_string_tuple(iterator, row_label_key(row_index), row->label, sizeof(row->label));
727  copy_string_tuple(iterator, row_detail_key(row_index), row->detail, sizeof(row->detail));
728
729  Tuple *percent_tuple = dict_find(iterator, row_percent_key(row_index));
730  row->percent = percent_tuple != NULL ? clamp_percent((int)percent_tuple->value->int32) : 0;
731
732  Tuple *status_tuple = dict_find(iterator, row_status_key(row_index));
733  row->status_rank = status_tuple != NULL ? (int)status_tuple->value->int32 : 1;
734
735  Tuple *has_bar_tuple = dict_find(iterator, row_has_bar_key(row_index));
736  row->has_bar = has_bar_tuple != NULL && has_bar_tuple->value->int32 != 0;
737}
738
739static void inbox_received_callback(DictionaryIterator *iterator, void *context) {
740  Tuple *error_tuple = dict_find(iterator, MESSAGE_KEY_ERROR);
741  if (error_tuple != NULL) {
742    s_is_refreshing = false;
743    s_row_count = 0;
744    s_page_index = 0;
745    s_page_count = 0;
746    snprintf(s_title_text, sizeof(s_title_text), "Pebblexus");
747    snprintf(s_reset_text, sizeof(s_reset_text), "%s", error_tuple->value->cstring);
748    snprintf(s_status_text, sizeof(s_status_text), "Select retries");
749    s_updated_text[0] = '\0';
750    snap_motion_idle();
751    mark_card_dirty();
752    return;
753  }
754
755  static char s_last_data_status[16] = "";
756  bool was_refreshing = s_is_refreshing;
757
758  s_is_refreshing = false;
759  copy_string_tuple(iterator, MESSAGE_KEY_QUOTA_TITLE, s_title_text, sizeof(s_title_text));
760  copy_string_tuple(iterator, MESSAGE_KEY_QUOTA_LABEL, s_group_text, sizeof(s_group_text));
761  copy_string_tuple(iterator, MESSAGE_KEY_QUOTA_RESET, s_reset_text, sizeof(s_reset_text));
762  copy_string_tuple(iterator, MESSAGE_KEY_QUOTA_STATUS, s_status_text, sizeof(s_status_text));
763  copy_string_tuple(iterator, MESSAGE_KEY_UPDATED_AT, s_updated_text, sizeof(s_updated_text));
764
765  // Redundant haptic feedback only when a refresh reveals a newly critical
766  // page; browsing between known-critical pages stays silent.
767  if (was_refreshing && strcmp(s_status_text, "CRITICAL") == 0 && strcmp(s_last_data_status, "CRITICAL") != 0)
768    vibes_short_pulse();
769  snprintf(s_last_data_status, sizeof(s_last_data_status), "%s", s_status_text);
770
771  Tuple *page_index_tuple = dict_find(iterator, MESSAGE_KEY_PAGE_INDEX);
772  if (page_index_tuple != NULL)
773    s_page_index = (int)page_index_tuple->value->int32;
774
775  Tuple *page_count_tuple = dict_find(iterator, MESSAGE_KEY_PAGE_COUNT);
776  if (page_count_tuple != NULL)
777    s_page_count = (int)page_count_tuple->value->int32;
778  if (s_page_count < 0)
779    s_page_count = 0;
780  if (s_page_index >= s_page_count)
781    s_page_index = s_page_count - 1;
782  if (s_page_index < 0)
783    s_page_index = 0;
784
785  Tuple *row_count_tuple = dict_find(iterator, MESSAGE_KEY_ROW_COUNT);
786  int row_count = row_count_tuple != NULL ? (int)row_count_tuple->value->int32 : 0;
787  if (row_count < 0)
788    row_count = 0;
789  s_row_count = row_count;
790
791  int rows_to_read = row_count > MAX_ROWS ? MAX_ROWS : row_count;
792  for (int i = 0; i < rows_to_read; i += 1) {
793    s_rows[i].label[0] = '\0';
794    s_rows[i].detail[0] = '\0';
795    s_rows[i].percent = 0;
796    s_rows[i].status_rank = 1;
797    s_rows[i].has_bar = false;
798    read_row(iterator, i);
799  }
800
801  if (s_motion_phase == MOTION_SLIDE_OUT || s_motion_phase == MOTION_WAITING) {
802    start_slide_in();
803  } else {
804    start_bar_cascade();
805  }
806}
807
808static void inbox_dropped_callback(AppMessageResult reason, void *context) {
809  bool was_refreshing = s_is_refreshing;
810  s_is_refreshing = false;
811  snprintf(s_status_text, sizeof(s_status_text), "Dropped");
812  start_slide_back();
813  if (was_refreshing)
814    start_press_shake();
815  mark_card_dirty();
816}
817
818static void outbox_failed_callback(DictionaryIterator *iterator, AppMessageResult reason, void *context) {
819  bool was_refreshing = s_is_refreshing;
820  s_is_refreshing = false;
821  snprintf(s_status_text, sizeof(s_status_text), "No phone");
822  start_slide_back();
823  if (was_refreshing)
824    start_press_shake();
825  mark_card_dirty();
826}
827
828static void main_window_load(Window *window) {
829  Layer *window_layer = window_get_root_layer(window);
830  GRect bounds = layer_get_bounds(window_layer);
831
832  s_card_layer = layer_create(bounds);
833  layer_set_update_proc(s_card_layer, card_update_proc);
834  layer_add_child(window_layer, s_card_layer);
835}
836
837static void main_window_unload(Window *window) {
838  if (s_animation_timer != NULL) {
839    app_timer_cancel(s_animation_timer);
840    s_animation_timer = NULL;
841  }
842  layer_destroy(s_card_layer);
843}
844
845static void init(void) {
846  s_main_window = window_create();
847  window_set_background_color(s_main_window, color_background());
848  window_set_click_config_provider(s_main_window, click_config_provider);
849  window_set_window_handlers(s_main_window, (WindowHandlers){.load = main_window_load, .unload = main_window_unload});
850
851  app_message_register_inbox_received(inbox_received_callback);
852  app_message_register_inbox_dropped(inbox_dropped_callback);
853  app_message_register_outbox_failed(outbox_failed_callback);
854  app_message_open(2048, 256);
855
856  window_stack_push(s_main_window, true);
857  request_quota();
858}
859
860static void deinit(void) { window_destroy(s_main_window); }
861
862int main(void) {
863  init();
864  app_event_loop();
865  deinit();
866}