#include "line_window.h" #include "alarm_target.h" #include "line_state.h" #include "anim.h" #include "badge.h" #include "detail_surface.h" #include "labels.h" #include "model.h" #include "msg.h" #include "disruption_window.h" #include "touch.h" #define HEADER_H 54 #define BANNER_H 40 #define CHEVRON_W 14 #define LINE_X 30 #define RIGHT_COL_W 58 #define BADGE_W 48 #define BADGE_H 32 #define SHAPE_MAX_POINTS 24 #define LINE_TIMEOUT_MS 8000 #define COUNTDOWN_H 76 #define MINI_MAX_H 64 #define MINI_MIN_H 24 static Window *s_window; static Layer *s_content; static Layer *s_countdown; static GRect s_countdown_rest; static char s_stop_id[ID_LEN]; static char s_line[LINE_LEN]; static char s_dest[NAME_LEN]; static bool s_flash; static bool s_was_loading; static int16_t s_shown_min1; static LineFsm s_fsm; static AppTimer *s_timeout_timer; static GPoint s_shape_points[SHAPE_MAX_POINTS]; static LineShapePoint s_shape_scaled[SHAPE_MAX_POINTS]; static LineDetail s_backup; static GBitmap *s_alarm_icon; static Label s_reason; static bool s_reason_on; static AppTimer *s_reason_timer; #define REASON_MS 1500 static LineDetail *active_detail(void) { return s_fsm.state == LINE_FSM_REFRESHING ? &s_backup : model_line_detail(); } static bool showing_content(void) { return s_fsm.state == LINE_FSM_COMPLETE || s_fsm.state == LINE_FSM_REFRESHING; } static GColor line_accent(void) { LineDetail *detail = active_detail(); if (detail->header_received) { return detail->color; } Stop *stop = model_stop_by_id(PAGE_TRANSIT, s_stop_id); if (stop) { for (uint8_t j = 0; j < stop->row_count; j++) { if (strcmp(stop->rows[j].line, s_line) == 0) { return stop->rows[j].color; } } } return GColorLightGray; } static void sync_loading(void) { bool loading = s_content && s_fsm.state == LINE_FSM_LOADING; if (loading == s_was_loading) { return; } s_was_loading = loading; if (loading) { anim_loading_start(s_content); } else { anim_loading_stop(); } } static void maybe_slide(void) { if (!s_countdown || !showing_content()) { return; } int16_t cur = line_countdown_minutes(active_detail()->min1, s_fsm.completed_at, time(NULL)); if (cur >= 0 && s_shown_min1 >= 0 && cur != s_shown_min1) { anim_slide_start(s_countdown, s_countdown_rest); } s_shown_min1 = cur; } static void cancel_timeout(void) { if (s_timeout_timer) { app_timer_cancel(s_timeout_timer); s_timeout_timer = NULL; } } static void timeout_handler(void *data); static void apply(LineFsmActions a) { if (a.cancel_timer) { cancel_timeout(); } if (a.restore_backup) { *model_line_detail() = s_backup; } if (a.save_backup) { s_backup = *model_line_detail(); model_line_begin(); } if (a.send_request) { msg_request_line(s_stop_id, s_line, s_dest); } if (a.arm_timer) { s_timeout_timer = app_timer_register(LINE_TIMEOUT_MS, timeout_handler, NULL); } if (a.redraw && s_content) { layer_mark_dirty(s_content); layer_mark_dirty(s_countdown); } } static void timeout_handler(void *data) { s_timeout_timer = NULL; apply(line_fsm_timeout(&s_fsm)); } static void draw_header(GContext *ctx, GRect bounds, LineDetail *detail) { graphics_context_set_fill_color(ctx, detail->color); graphics_fill_rect(ctx, bounds, 0, GCornerNone); GRect badge = GRect(bounds.origin.x + 8, bounds.origin.y + (bounds.size.h - BADGE_H) / 2, BADGE_W, BADGE_H); draw_line_badge(ctx, badge, detail->line, detail->color, detail->text_color); graphics_context_set_stroke_color(ctx, detail->text_color); graphics_context_set_stroke_width(ctx, 2); graphics_draw_round_rect(ctx, badge, 6); int16_t dest_x = badge.origin.x + BADGE_W + 8; graphics_context_set_text_color(ctx, detail->text_color); graphics_draw_text(ctx, detail->destination, fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), GRect(dest_x, bounds.origin.y + (bounds.size.h - 22) / 2, bounds.origin.x + bounds.size.w - dest_x - 4, 22), GTextOverflowModeTrailingEllipsis, GTextAlignmentLeft, NULL); draw_clock(ctx, bounds, detail->text_color); } static void draw_shape(GContext *ctx, GRect body, LineDetail *detail) { int16_t top = body.origin.y + COUNTDOWN_H + 2; int16_t bottom = body.origin.y + body.size.h - 4; int16_t box_h = bottom - top; if (box_h < MINI_MIN_H) { return; } if (box_h > MINI_MAX_H) { top = bottom - MINI_MAX_H; box_h = MINI_MAX_H; } GRect box = GRect(body.size.w - RIGHT_COL_W + 4, top, RIGHT_COL_W - 12, box_h); uint8_t point_count = line_shape_project(detail->shape, detail->shape_len, box.size.w - 4, box.size.h - 4, s_shape_scaled, SHAPE_MAX_POINTS); if (point_count < 2) { return; } for (uint8_t i = 0; i < point_count; i++) { s_shape_points[i] = GPoint(box.origin.x + 2 + s_shape_scaled[i].x, box.origin.y + 2 + s_shape_scaled[i].y); } GPath path = { .num_points = point_count, .points = s_shape_points, .rotation = 0, .offset = GPointZero }; graphics_context_set_stroke_color(ctx, GColorDarkGray); graphics_context_set_stroke_width(ctx, 1); gpath_draw_outline_open(ctx, &path); } static void draw_strip(GContext *ctx, GRect body, LineDetail *detail) { int16_t strip_w = body.size.w - RIGHT_COL_W; if (detail->strip_count == 0) { detail_surface_draw_empty(ctx, GRect(0, body.origin.y, strip_w, body.size.h), LABEL_NO_LINE, GTextAlignmentCenter); return; } uint8_t start = 0; uint8_t visible = detail->strip_count; int16_t row_h = body.size.h / visible; if (row_h < 14) { visible = body.size.h / 14; if (visible == 0) { visible = 1; } if (visible > detail->strip_count) { visible = detail->strip_count; } start = detail->strip_count - visible; row_h = body.size.h / visible; } if (row_h > 34) { row_h = 34; } graphics_context_set_stroke_color(ctx, GColorDarkGray); graphics_context_set_stroke_width(ctx, 1); graphics_draw_line(ctx, GPoint(LINE_X, body.origin.y + 4), GPoint(LINE_X, body.origin.y + row_h * visible - 4)); for (uint8_t i = 0; i < visible; i++) { StripEntry *entry = &detail->strip[start + i]; int16_t y = body.origin.y + i * row_h; int16_t cy = y + row_h / 2; GColor name_color = GColorLightGray; GFont name_font = fonts_get_system_font(FONT_KEY_GOTHIC_14); if (entry->marker == 2) { GPoint center = GPoint(LINE_X, cy); graphics_context_set_fill_color(ctx, detail->color); graphics_fill_circle(ctx, center, 9); graphics_context_set_fill_color(ctx, GColorWhite); graphics_fill_circle(ctx, center, 6); name_color = GColorWhite; name_font = fonts_get_system_font(FONT_KEY_GOTHIC_14_BOLD); } else if (entry->marker == 1) { graphics_context_set_fill_color(ctx, detail->color); graphics_fill_circle(ctx, GPoint(LINE_X, cy), 6 + anim_pulse_delta()); name_color = GColorWhite; } else { GPoint center = GPoint(LINE_X, cy); graphics_context_set_fill_color(ctx, GColorBlack); graphics_fill_circle(ctx, center, 4); graphics_context_set_stroke_color(ctx, GColorLightGray); graphics_context_set_stroke_width(ctx, 1); graphics_draw_circle(ctx, center, 4); } graphics_context_set_text_color(ctx, name_color); graphics_draw_text(ctx, entry->name, name_font, GRect(LINE_X + 12, y + 1, strip_w - LINE_X - 16, 16), GTextOverflowModeTrailingEllipsis, GTextAlignmentLeft, NULL); if (entry->marker == 1 && row_h >= 30) { graphics_context_set_text_color(ctx, detail->color); graphics_draw_text(ctx, "vehicle", fonts_get_system_font(FONT_KEY_GOTHIC_14), GRect(LINE_X + 12, y + 16, strip_w - LINE_X - 16, 14), GTextOverflowModeTrailingEllipsis, GTextAlignmentLeft, NULL); } } } static GFont countdown_pick_font(const char *buf, GRect *box) { GFont primary = fonts_get_system_font(FONT_KEY_BITHAM_42_BOLD); *box = GRect(20, 8, RIGHT_COL_W - 4, 46); GSize sz = graphics_text_layout_get_content_size(buf, primary, GRect(0, 0, 200, 60), GTextOverflowModeFill, GTextAlignmentLeft); if (sz.w <= box->size.w) { return primary; } int16_t right = box->origin.x + box->size.w; int16_t wide_w = RIGHT_COL_W + 12; *box = GRect(right - wide_w, 12, wide_w, 42); return fonts_get_system_font(FONT_KEY_BITHAM_30_BLACK); } static void countdown_update(Layer *layer, GContext *ctx) { if (!showing_content() || s_flash) { return; } LineDetail *detail = active_detail(); GRect bounds = layer_get_bounds(layer); char buf[16]; bool stale = model_stale(PAGE_TRANSIT, time(NULL)); int16_t min1 = line_countdown_minutes(detail->min1, s_fsm.completed_at, time(NULL)); int16_t min2 = line_countdown_minutes(detail->min2, s_fsm.completed_at, time(NULL)); bool armed = alarm_target_armed(s_stop_id, s_line) && s_alarm_icon; GRect bell = GRect(2, 24, 0, 0); if (armed) { bell.size = gbitmap_get_bounds(s_alarm_icon).size; } if (min1 >= 0) { graphics_context_set_text_color(ctx, stale ? GColorLightGray : GColorWhite); snprintf(buf, sizeof(buf), "%d'", min1); GRect box; GFont font = countdown_pick_font(buf, &box); graphics_draw_text(ctx, buf, font, box, GTextOverflowModeTrailingEllipsis, GTextAlignmentRight, NULL); if (armed) { GSize sz = graphics_text_layout_get_content_size(buf, font, GRect(0, 0, 200, 60), GTextOverflowModeFill, GTextAlignmentLeft); bell.origin.x = box.origin.x + box.size.w - sz.w - 4 - bell.size.w; bell.origin.y = box.origin.y + (sz.h - bell.size.h) / 2 + sz.h / 5; } } if (armed) { graphics_context_set_compositing_mode(ctx, GCompOpSet); graphics_draw_bitmap_in_rect(ctx, s_alarm_icon, bell); } if (s_reason_on) { detail_surface_draw_reason(ctx, GRect(bounds.size.w - 4 - 150, 56, 150, 16), s_reason); } else if (min2 >= 0) { graphics_context_set_text_color(ctx, stale ? GColorDarkGray : GColorLightGray); snprintf(buf, sizeof(buf), "then %d'", min2); graphics_draw_text(ctx, buf, fonts_get_system_font(FONT_KEY_GOTHIC_14), GRect(0, 56, bounds.size.w - 4, 16), GTextOverflowModeTrailingEllipsis, GTextAlignmentRight, NULL); } } static void draw_chevron(GContext *ctx, GPoint center) { graphics_context_set_stroke_color(ctx, GColorBlack); graphics_context_set_stroke_width(ctx, 2); graphics_draw_line(ctx, GPoint(center.x - 4, center.y - 2), GPoint(center.x, center.y + 2)); graphics_draw_line(ctx, GPoint(center.x, center.y + 2), GPoint(center.x + 4, center.y - 2)); } static void draw_banner(GContext *ctx, GRect bounds, LineDetail *detail) { GRect banner = GRect(0, bounds.size.h - BANNER_H, bounds.size.w, BANNER_H); graphics_context_set_fill_color(ctx, GColorChromeYellow); graphics_fill_rect(ctx, banner, 0, GCornerNone); graphics_context_set_text_color(ctx, GColorBlack); GFont font = fonts_get_system_font(FONT_KEY_GOTHIC_14); int16_t full_w = bounds.size.w - 12; GSize natural = graphics_text_layout_get_content_size(detail->disruption, font, GRect(0, 0, full_w * 4, BANNER_H), GTextOverflowModeFill, GTextAlignmentLeft); bool overflow = natural.w > full_w; int16_t text_w = overflow ? full_w - CHEVRON_W : full_w; graphics_draw_text(ctx, detail->disruption, font, GRect(6, banner.origin.y + 2, text_w, BANNER_H - 4), GTextOverflowModeTrailingEllipsis, GTextAlignmentLeft, NULL); if (overflow) { draw_chevron(ctx, GPoint(bounds.size.w - 10, banner.origin.y + BANNER_H - 10)); } } static void content_update(Layer *layer, GContext *ctx) { GRect bounds = layer_get_bounds(layer); graphics_context_set_fill_color(ctx, GColorBlack); graphics_fill_rect(ctx, bounds, 0, GCornerNone); if (!showing_content() || s_flash) { Label label = s_fsm.state == LINE_FSM_FAILED ? LABEL_NO_NETWORK : s_fsm.state == LINE_FSM_UNKNOWN ? LABEL_NO_LINE : LABEL_LOADING; if (!s_flash && label == LABEL_LOADING) { anim_draw_loading(ctx, bounds, line_accent()); return; } detail_surface_draw_empty(ctx, bounds, label, GTextAlignmentCenter); return; } LineDetail *detail = active_detail(); draw_header(ctx, GRect(0, 0, bounds.size.w, HEADER_H), detail); bool has_banner = detail->disruption[0] != '\0'; int16_t body_h = bounds.size.h - HEADER_H - (has_banner ? BANNER_H : 0); GRect body = GRect(0, HEADER_H, bounds.size.w, body_h); draw_shape(ctx, body, detail); draw_strip(ctx, body, detail); if (has_banner) { draw_banner(ctx, bounds, detail); } } static uint16_t stop_distance(void) { Stop *stop = model_stop_by_id(PAGE_TRANSIT, s_stop_id); return stop ? stop->distance_m : 0; } static void flash_timeout(void *data) { s_flash = false; if (s_content) { layer_mark_dirty(s_content); } } static void flash_cannot(void) { s_flash = true; if (s_content) { layer_mark_dirty(s_content); } app_timer_register(700, flash_timeout, NULL); } static void reason_timeout(void *data) { s_reason_timer = NULL; s_reason_on = false; if (s_countdown) { layer_mark_dirty(s_countdown); } } static void show_reason(Label label) { s_reason = label; s_reason_on = true; if (s_reason_timer) { app_timer_cancel(s_reason_timer); } s_reason_timer = app_timer_register(REASON_MS, reason_timeout, NULL); if (s_countdown) { layer_mark_dirty(s_countdown); } } static void toggle_alarm(void) { LineDetail *detail = model_line_detail(); time_t now = time(NULL); AlarmTarget target = { .base = s_fsm.completed_at ? s_fsm.completed_at : now, .minutes = detail->min1, .walk_seconds = stop_distance(), .stop_id = s_stop_id, .line = s_line, .kind = ALARM_WAKE_LINE, }; switch (alarm_target_toggle(&target, s_fsm.state == LINE_FSM_COMPLETE, now)) { case ALARM_ARM: case ALARM_DISARM: layer_mark_dirty(s_countdown); layer_mark_dirty(s_content); break; case ALARM_BLOCKED_LOADING: case ALARM_BLOCKED_CANNOT_SCHEDULE: flash_cannot(); break; case ALARM_BLOCKED_NO_DEPARTURE: show_reason(LABEL_NO_DEPARTURE); break; case ALARM_BLOCKED_TOO_LATE: show_reason(LABEL_TOO_LATE); break; } } static void select_long_click(ClickRecognizerRef ref, void *ctx) { toggle_alarm(); } static void touch_tap(GPoint p, void *ctx) { LineDetail *detail = active_detail(); GRect bounds = layer_get_bounds(s_content); if (detail->disruption[0] && p.y >= bounds.size.h - BANNER_H) { disruption_window_push(detail); return; } line_window_poll(); } static void touch_hold(GPoint p, void *ctx) { toggle_alarm(); } static void touch_swipe(TouchSwipeDir dir, void *ctx) { if (dir == TOUCH_SWIPE_RIGHT) { window_stack_pop(true); } } static void select_click(ClickRecognizerRef ref, void *ctx) { line_window_poll(); } static void down_click(ClickRecognizerRef ref, void *ctx) { LineDetail *detail = active_detail(); if (detail->disruption[0] == '\0') { return; } disruption_window_push(detail); } static void click_config(void *ctx) { window_long_click_subscribe(BUTTON_ID_SELECT, 500, select_long_click, NULL); window_single_click_subscribe(BUTTON_ID_SELECT, select_click); window_single_click_subscribe(BUTTON_ID_DOWN, down_click); } static void window_load(Window *window) { Layer *root = window_get_root_layer(window); GRect bounds = layer_get_bounds(root); s_alarm_icon = gbitmap_create_with_resource(RESOURCE_ID_IMAGE_ICON_ALARM); s_content = layer_create(bounds); layer_set_update_proc(s_content, content_update); layer_add_child(root, s_content); s_countdown_rest = GRect(bounds.size.w - RIGHT_COL_W - 20, HEADER_H, RIGHT_COL_W + 20, COUNTDOWN_H); s_countdown = layer_create(s_countdown_rest); layer_set_update_proc(s_countdown, countdown_update); layer_set_clips(s_countdown, false); layer_add_child(root, s_countdown); touch_attach(window, &(TouchHandlers){ .on_tap = touch_tap, .on_hold = touch_hold, .on_swipe = touch_swipe, }); sync_loading(); } static void window_unload(Window *window) { apply(line_fsm_unload(&s_fsm)); anim_layer_gone(s_content); anim_layer_gone(s_countdown); s_was_loading = false; if (s_reason_timer) { app_timer_cancel(s_reason_timer); s_reason_timer = NULL; } s_reason_on = false; gbitmap_destroy(s_alarm_icon); s_alarm_icon = NULL; layer_destroy(s_countdown); s_countdown = NULL; layer_destroy(s_content); s_content = NULL; touch_detach(window); window_destroy(window); s_window = NULL; } void line_window_push_label(const char *stop_id, const char *line, const char *destination) { detail_surface_pin(s_stop_id, stop_id, ID_LEN); detail_surface_pin(s_line, line, LINE_LEN); detail_surface_pin(s_dest, destination ? destination : "", NAME_LEN); s_flash = false; s_was_loading = false; s_shown_min1 = -1; model_line_begin(); apply(line_fsm_push(&s_fsm)); s_window = window_create(); window_set_window_handlers(s_window, (WindowHandlers){ .load = window_load, .unload = window_unload, }); window_set_click_config_provider(s_window, click_config); window_stack_push(s_window, true); } void line_window_refresh(void) { if (!s_window || !s_content) { return; } LineDetail *detail = model_line_detail(); time_t prev_completed = s_fsm.completed_at; apply(line_fsm_refresh(&s_fsm, detail->status == 1, model_line_complete(), time(NULL))); if (s_fsm.state == LINE_FSM_COMPLETE && s_fsm.completed_at != prev_completed) { anim_pulse_start(s_content); } sync_loading(); maybe_slide(); layer_mark_dirty(s_countdown); } void line_window_poll(void) { if (!s_window) { return; } apply(line_fsm_poll(&s_fsm, time(NULL))); sync_loading(); } void line_window_fail(void) { if (!s_window) { return; } apply(line_fsm_fail(&s_fsm)); sync_loading(); }