main.c

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