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}