/* * MTG RFID Companion - UI task with LVGL */ #include #include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_log.h" #include "esp_random.h" #include "driver/gpio.h" #include "hw_pins.h" #include "cJSON.h" #include "display_manager.h" #include "rfid_manager.h" #include "storage_manager.h" #include "scryfall_client.h" #include "wifi_manager.h" #include "app_state.h" #include "encoder.h" #include "buzzer.h" #include "jpeg_decoder.h" #include "ota_manager.h" #include "ui_task.h" static const char *TAG = "ui"; #define UI_TASK_STACK (8 * 1024) #define UI_TASK_PRIO (5) #define UI_CORE (1) #define COLOR_AMBER (0xFFB300) #define COLOR_GOLD (0xFFCC00) static TaskHandle_t s_ui_task = NULL; static char s_uid[RFID_UID_STR_MAX_LEN] = {0}; static char s_card_name[128] = {0}; static bool s_waiting_fetch = false; static lv_img_dsc_t s_art_img_dsc; static uint16_t *s_art_pixels = NULL; /* Menu items */ #define MENU_ITEM_COUNT 8 static int s_menu_idx = 0; static const char *s_menu_labels[MENU_ITEM_COUNT] = { "1. CARD SCANNER", "2. LIFE & COUNTERS", "3. DICE & COIN FLIP", "4. COMMANDER TAX", "5. TOKEN & COUNTERS", "6. DECK STORAGE", "7. DEVICE & WIFI INFO", "8. CHECK FIRMWARE OTA" }; /* Life & Counters */ typedef enum { LIFE_MODE_TOTAL = 0, LIFE_MODE_POISON, LIFE_MODE_CMD_DMG, } life_counter_mode_t; static life_counter_mode_t s_life_mode = LIFE_MODE_TOTAL; static int32_t s_poison = 0; static int32_t s_cmd_dmg = 0; static bool s_was_lethal = false; /* Dice & Coin */ typedef enum { DICE_MODE_D20 = 0, DICE_MODE_D6, DICE_MODE_COIN, } dice_mode_t; static dice_mode_t s_dice_mode = DICE_MODE_D20; static int s_dice_result = 20; static bool s_dice_rolled = false; /* Commander Tax & Turn Tracker */ static int s_cmd_casts = 0; static int s_turn_count = 1; static bool s_tax_edit_turns = false; /* Token & Counter Spawner */ typedef struct { const char *name; const char *type_line; const char *pt; const char *oracle; uint32_t color_hex; int count; } mtg_token_t; #define TOKEN_COUNT 8 static mtg_token_t s_tokens[TOKEN_COUNT] = { {"TREASURE", "Artifact Token", "", "{T}, Sacrifice: Add one mana of any color.", 0xFFB300, 0}, {"FOOD", "Artifact Token", "", "{2}, {T}, Sacrifice: You gain 3 life.", 0x00E676, 0}, {"CLUE", "Artifact Token", "", "{2}, Sacrifice: Draw a card.", 0x00E5FF, 0}, {"SOLDIER", "Creature Token — Soldier", "1 / 1", "White Soldier creature token.", 0xFFFFFF, 0}, {"ZOMBIE", "Creature Token — Zombie", "2 / 2", "Black Zombie creature token.", 0xBA68C8, 0}, {"GOBLIN", "Creature Token — Goblin", "1 / 1", "Red Goblin creature token.", 0xFF5252, 0}, {"BEAST", "Creature Token — Beast", "3 / 3", "Green Beast creature token.", 0x66BB6A, 0}, {"+1/+1 COUNTER", "Stat Counter", "", "Put a +1/+1 counter on target permanent.", 0xFFD700, 0}, }; static int s_token_idx = 0; static bool s_token_edit_count = false; /* Storage Manager */ static bool s_storage_confirm_dialog = false; static int s_storage_option_idx = 0; /* Button holding timer */ static uint32_t s_btn_held_ms = 0; static bool s_btn_long_fired = false; /* OTA progress widgets */ static lv_obj_t *s_ota_bar = NULL; static lv_obj_t *s_ota_pct_lbl = NULL; static lv_obj_t *s_ota_bytes_lbl = NULL; /* Forward declarations */ static void view_menu(void); static void view_scanner(void); static void view_card(void); static void view_life(void); static void view_dice(void); static void view_commander_tax(void); static void view_token_spawner(void); static void view_storage(void); static void view_device_info(void); static void view_unmapped(const char *uid); static void view_fetching(const char *name); static void view_ota(void); static void free_art_buffer(void) { if (s_art_pixels != NULL) { free(s_art_pixels); s_art_pixels = NULL; } } static bool load_art_crop(const char *uid) { free_art_buffer(); char jpg_path[72]; snprintf(jpg_path, sizeof(jpg_path), STORAGE_CARDS_DIR "/%s.jpg", uid); if (!storage_file_exists(jpg_path)) { return false; } FILE *f = fopen(jpg_path, "rb"); if (f == NULL) { return false; } fseek(f, 0, SEEK_END); long fsize = ftell(f); fseek(f, 0, SEEK_SET); if (fsize <= 0 || fsize > 250 * 1024) { fclose(f); return false; } uint8_t *jpg_buf = malloc((size_t)fsize); if (jpg_buf == NULL) { fclose(f); return false; } if (fread(jpg_buf, 1, (size_t)fsize, f) != (size_t)fsize) { free(jpg_buf); fclose(f); return false; } fclose(f); esp_jpeg_image_cfg_t cfg = { .indata = jpg_buf, .indata_size = (uint32_t)fsize, .outbuf = NULL, .outbuf_size = 0, .out_format = JPEG_IMAGE_FORMAT_RGB565, .out_scale = JPEG_IMAGE_SCALE_0, .flags = { .swap_color_bytes = 1, }, }; esp_jpeg_image_output_t out_info = {0}; if (esp_jpeg_get_image_info(&cfg, &out_info) != ESP_OK) { ESP_LOGW(TAG, "Unsupported or corrupt JPEG: %s", jpg_path); free(jpg_buf); remove(jpg_path); return false; } if (out_info.width > 240) { cfg.out_scale = (out_info.width > 480) ? JPEG_IMAGE_SCALE_1_4 : JPEG_IMAGE_SCALE_1_2; esp_jpeg_get_image_info(&cfg, &out_info); } s_art_pixels = malloc(out_info.output_len); if (s_art_pixels == NULL) { ESP_LOGE(TAG, "Failed to allocate %d bytes for art crop", (int)out_info.output_len); free(jpg_buf); return false; } cfg.outbuf = (uint8_t *)s_art_pixels; cfg.outbuf_size = (uint32_t)out_info.output_len; esp_err_t err = esp_jpeg_decode(&cfg, &out_info); free(jpg_buf); if (err != ESP_OK) { ESP_LOGW(TAG, "Decode failed for %s, removing", jpg_path); remove(jpg_path); free_art_buffer(); return false; } s_art_img_dsc.header.always_zero = 0; s_art_img_dsc.header.w = out_info.width; s_art_img_dsc.header.h = out_info.height; s_art_img_dsc.data_size = out_info.output_len; s_art_img_dsc.header.cf = LV_IMG_CF_TRUE_COLOR; s_art_img_dsc.data = (const uint8_t *)s_art_pixels; ESP_LOGI(TAG, "Decoded art crop: %dx%d (%d bytes)", out_info.width, out_info.height, (int)out_info.output_len); return true; } static const char *get_json_str(cJSON *root, const char *key, const char *fallback) { cJSON *item = cJSON_GetObjectItem(root, key); if (item != NULL && cJSON_IsString(item) && item->valuestring != NULL) { return item->valuestring; } return fallback; } /* ------------------------------------------------------------------ */ /* LVGL Screens */ /* ------------------------------------------------------------------ */ static void ui_clean_screen(void) { free_art_buffer(); s_ota_bar = NULL; s_ota_pct_lbl = NULL; s_ota_bytes_lbl = NULL; lv_obj_clean(lv_scr_act()); lv_obj_set_style_bg_color(lv_scr_act(), lv_color_black(), 0); lv_obj_set_style_bg_opa(lv_scr_act(), LV_OPA_COVER, 0); } /* ponytail: single shared header builder across all screens */ static void ui_draw_header(const char *title, const char *subtitle) { lv_obj_t *bar = lv_obj_create(lv_scr_act()); lv_obj_set_size(bar, DISP_W, 4); lv_obj_set_style_bg_color(bar, lv_color_hex(COLOR_GOLD), 0); lv_obj_set_style_border_width(bar, 0, 0); lv_obj_align(bar, LV_ALIGN_TOP_MID, 0, 0); if (title != NULL) { lv_obj_t *t = lv_label_create(lv_scr_act()); lv_label_set_text(t, title); lv_obj_set_style_text_font(t, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(t, lv_color_hex(COLOR_GOLD), 0); lv_obj_align(t, LV_ALIGN_TOP_MID, 0, (subtitle != NULL) ? 10 : 14); } if (subtitle != NULL) { lv_obj_t *sub = lv_label_create(lv_scr_act()); lv_label_set_text(sub, subtitle); lv_obj_set_style_text_font(sub, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(sub, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 34); } } /* 1. Main Menu */ static void view_menu(void) { encoder_set_steps_per_detent(4); lvgl_port_lock(0); ui_clean_screen(); ui_draw_header("MTG COMPANION", "- MAIN MENU -"); /* 8 Menu Items */ int start_y = 44; for (int i = 0; i < MENU_ITEM_COUNT; i++) { bool selected = (i == s_menu_idx); lv_obj_t *item_box = lv_obj_create(lv_scr_act()); lv_obj_set_size(item_box, DISP_W - 24, 28); lv_obj_align(item_box, LV_ALIGN_TOP_MID, 0, start_y + i * 31); lv_obj_set_style_radius(item_box, 6, 0); lv_obj_set_style_pad_all(item_box, 2, 0); lv_obj_set_scrollbar_mode(item_box, LV_SCROLLBAR_MODE_OFF); if (selected) { lv_obj_set_style_bg_color(item_box, lv_color_hex(0xFFCC00), 0); lv_obj_set_style_border_color(item_box, lv_color_white(), 0); lv_obj_set_style_border_width(item_box, 2, 0); } else { lv_obj_set_style_bg_color(item_box, lv_color_hex(0x1C1C1C), 0); lv_obj_set_style_border_color(item_box, lv_color_hex(0x333333), 0); lv_obj_set_style_border_width(item_box, 1, 0); } lv_obj_t *lbl = lv_label_create(item_box); if (selected) { lv_label_set_text_fmt(lbl, "> %s <", s_menu_labels[i]); lv_obj_set_style_text_color(lbl, lv_color_black(), 0); } else { lv_label_set_text_fmt(lbl, " %s", s_menu_labels[i]); lv_obj_set_style_text_color(lbl, lv_color_white(), 0); } lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); lv_obj_align(lbl, LV_ALIGN_CENTER, 0, 0); } /* Bottom navigation hint */ lv_obj_t *hint = lv_label_create(lv_scr_act()); lv_label_set_text(hint, "ROTATE: NAV • CLICK: SELECT"); lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -6); lvgl_port_unlock(); } /* 2. Card Scanner */ static void view_scanner(void) { lvgl_port_lock(0); ui_clean_screen(); lv_obj_t *title = lv_label_create(lv_scr_act()); lv_label_set_text(title, "CARD SCANNER"); lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(title, lv_color_hex(0xFFCC00), 0); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 25); lv_obj_t *box = lv_obj_create(lv_scr_act()); lv_obj_set_size(box, DISP_W - 32, 140); lv_obj_align(box, LV_ALIGN_CENTER, 0, -15); lv_obj_set_style_bg_color(box, lv_color_hex(0x181818), 0); lv_obj_set_style_border_color(box, lv_color_hex(0xFFCC00), 0); lv_obj_set_style_border_width(box, 2, 0); lv_obj_set_style_radius(box, 10, 0); lv_obj_t *sub = lv_label_create(box); lv_label_set_text(sub, "TAP RFID CARD\nTO SCAN"); lv_obj_set_style_text_font(sub, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(sub, lv_color_white(), 0); lv_obj_set_style_text_align(sub, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(sub, LV_ALIGN_CENTER, 0, 0); if (s_uid[0] != '\0') { lv_obj_t *uid_lbl = lv_label_create(lv_scr_act()); lv_label_set_text_fmt(uid_lbl, "LAST SCANNED: %s", s_uid); lv_obj_set_style_text_font(uid_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(uid_lbl, lv_color_hex(0x00E5FF), 0); lv_obj_align(uid_lbl, LV_ALIGN_BOTTOM_MID, 0, -50); } lv_obj_t *hint1 = lv_label_create(lv_scr_act()); lv_label_set_text(hint1, "HOLD BUTTON: MAIN MENU"); lv_obj_set_style_text_font(hint1, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(hint1, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(hint1, LV_ALIGN_BOTTOM_MID, 0, -28); lv_obj_t *hint2 = lv_label_create(lv_scr_act()); lv_label_set_text(hint2, "CLICK BUTTON: LIFE TOTAL"); lv_obj_set_style_text_font(hint2, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(hint2, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(hint2, LV_ALIGN_BOTTOM_MID, 0, -10); lvgl_port_unlock(); } /* 3. Life & Counters */ static void view_life(void) { lvgl_port_lock(0); ui_clean_screen(); /* Top yellow accent bar */ lv_obj_t *bar = lv_obj_create(lv_scr_act()); lv_obj_set_size(bar, DISP_W, 4); lv_obj_set_style_bg_color(bar, lv_color_hex(0xFFCC00), 0); lv_obj_set_style_border_width(bar, 0, 0); lv_obj_align(bar, LV_ALIGN_TOP_MID, 0, 0); /* Counter Selector Bar */ const char *modes[3] = {"LIFE", "POISON", "CMD"}; int tab_w = (DISP_W - 24) / 3; for (int i = 0; i < 3; i++) { bool active = (i == (int)s_life_mode); lv_obj_t *tbox = lv_obj_create(lv_scr_act()); lv_obj_set_size(tbox, tab_w, 30); lv_obj_align(tbox, LV_ALIGN_TOP_LEFT, 12 + i * tab_w, 10); lv_obj_set_style_radius(tbox, 4, 0); if (active) { lv_obj_set_style_bg_color(tbox, lv_color_hex(0xFFCC00), 0); lv_obj_set_style_border_color(tbox, lv_color_white(), 0); lv_obj_set_style_border_width(tbox, 2, 0); } else { lv_obj_set_style_bg_color(tbox, lv_color_hex(0x202020), 0); lv_obj_set_style_border_color(tbox, lv_color_hex(0x333333), 0); lv_obj_set_style_border_width(tbox, 1, 0); } lv_obj_t *tlbl = lv_label_create(tbox); lv_label_set_text(tlbl, modes[i]); lv_obj_set_style_text_font(tlbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(tlbl, active ? lv_color_black() : lv_color_hex(COLOR_AMBER), 0); lv_obj_align(tlbl, LV_ALIGN_CENTER, 0, 0); } /* Main Big Number Display */ lv_obj_t *life_label = lv_label_create(lv_scr_act()); lv_obj_set_style_text_font(life_label, &lv_font_montserrat_48, 0); lv_obj_align(life_label, LV_ALIGN_CENTER, 0, -20); bool lethal = false; if (s_life_mode == LIFE_MODE_TOTAL) { int32_t life = app_get_life(); lv_label_set_text_fmt(life_label, "%ld", (long)life); lv_color_t color = (life <= 0) ? lv_color_hex(0xFF1744) : (life <= 10) ? lv_color_hex(0xFF9100) : (life <= 15) ? lv_color_hex(0xFFCC00) : lv_color_hex(0x00E676); lv_obj_set_style_text_color(life_label, color, 0); if (life <= 0) lethal = true; } else if (s_life_mode == LIFE_MODE_POISON) { lv_label_set_text_fmt(life_label, "%ld / 10", (long)s_poison); lv_color_t color = (s_poison >= 10) ? lv_color_hex(0xFF1744) : (s_poison >= 6) ? lv_color_hex(0xFF9100) : lv_color_hex(0x00E5FF); lv_obj_set_style_text_color(life_label, color, 0); if (s_poison >= 10) lethal = true; } else { lv_label_set_text_fmt(life_label, "%ld / 21", (long)s_cmd_dmg); lv_color_t color = (s_cmd_dmg >= 21) ? lv_color_hex(0xFF1744) : (s_cmd_dmg >= 15) ? lv_color_hex(0xFF9100) : lv_color_hex(0xE040FB); lv_obj_set_style_text_color(life_label, color, 0); if (s_cmd_dmg >= 21) lethal = true; } if (lethal) { if (!s_was_lethal) { s_was_lethal = true; buzzer_play_sound(BUZZER_SOUND_LOSS); } lv_obj_t *dead_lbl = lv_label_create(lv_scr_act()); lv_label_set_text(dead_lbl, "*** LETHAL / DEFEAT ***"); lv_obj_set_style_text_font(dead_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(dead_lbl, lv_color_hex(0xFF1744), 0); lv_obj_align(dead_lbl, LV_ALIGN_CENTER, 0, 30); } else { s_was_lethal = false; } /* Subtitle details */ lv_obj_t *preset_lbl = lv_label_create(lv_scr_act()); if (s_life_mode == LIFE_MODE_TOTAL) { lv_label_set_text_fmt(preset_lbl, "PRESET: %ld (CLICK: CYCLE)", (long)app_get_life_preset()); } else if (s_life_mode == LIFE_MODE_POISON) { lv_label_set_text(preset_lbl, "10 POISON COUNTERS = LOSS"); } else { lv_label_set_text(preset_lbl, "21 COMBAT DAMAGE = LOSS"); } lv_obj_set_style_text_font(preset_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(preset_lbl, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(preset_lbl, LV_ALIGN_BOTTOM_MID, 0, -35); lv_obj_t *hint = lv_label_create(lv_scr_act()); lv_label_set_text(hint, "ROTATE: +/- • HOLD: MENU"); lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -10); lvgl_port_unlock(); } /* 4. Dice & Coin Flip */ static void view_dice(void) { lvgl_port_lock(0); ui_clean_screen(); ui_draw_header(NULL, NULL); /* Selector tabs */ const char *tabs[3] = {"[ D20 ]", "[ D6 ]", "[ COIN ]"}; int tab_w = (DISP_W - 24) / 3; for (int i = 0; i < 3; i++) { bool active = (i == (int)s_dice_mode); lv_obj_t *tbox = lv_obj_create(lv_scr_act()); lv_obj_set_size(tbox, tab_w, 32); lv_obj_align(tbox, LV_ALIGN_TOP_LEFT, 12 + i * tab_w, 12); lv_obj_set_style_radius(tbox, 4, 0); if (active) { lv_obj_set_style_bg_color(tbox, lv_color_hex(0xFFCC00), 0); lv_obj_set_style_border_color(tbox, lv_color_white(), 0); lv_obj_set_style_border_width(tbox, 2, 0); } else { lv_obj_set_style_bg_color(tbox, lv_color_hex(0x222222), 0); lv_obj_set_style_border_color(tbox, lv_color_hex(0x444444), 0); lv_obj_set_style_border_width(tbox, 1, 0); } lv_obj_t *tlbl = lv_label_create(tbox); lv_label_set_text(tlbl, tabs[i]); lv_obj_set_style_text_font(tlbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(tlbl, active ? lv_color_black() : lv_color_hex(COLOR_AMBER), 0); lv_obj_align(tlbl, LV_ALIGN_CENTER, 0, 0); } /* Result Container Box */ lv_obj_t *rbox = lv_obj_create(lv_scr_act()); lv_obj_set_size(rbox, DISP_W - 24, 180); lv_obj_align(rbox, LV_ALIGN_CENTER, 0, -10); lv_obj_set_style_bg_color(rbox, lv_color_hex(0x181818), 0); lv_obj_set_style_border_color(rbox, lv_color_hex(0xFFCC00), 0); lv_obj_set_style_border_width(rbox, 2, 0); lv_obj_set_style_radius(rbox, 12, 0); lv_obj_t *res_lbl = lv_label_create(rbox); lv_obj_set_style_text_font(res_lbl, &lv_font_montserrat_48, 0); if (s_dice_mode == DICE_MODE_COIN) { if (!s_dice_rolled) { lv_label_set_text(res_lbl, "?"); lv_obj_set_style_text_color(res_lbl, lv_color_hex(COLOR_AMBER), 0); } else if (s_dice_result == 1) { lv_label_set_text(res_lbl, "HEADS"); lv_obj_set_style_text_font(res_lbl, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(res_lbl, lv_color_hex(0x00E676), 0); } else { lv_label_set_text(res_lbl, "TAILS"); lv_obj_set_style_text_font(res_lbl, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(res_lbl, lv_color_hex(0xFF9100), 0); } } else { if (!s_dice_rolled) { lv_label_set_text(res_lbl, s_dice_mode == DICE_MODE_D20 ? "D20" : "D6"); lv_obj_set_style_text_font(res_lbl, &lv_font_montserrat_48, 0); lv_obj_set_style_text_color(res_lbl, lv_color_hex(COLOR_AMBER), 0); } else { lv_label_set_text_fmt(res_lbl, "%d", s_dice_result); if (s_dice_mode == DICE_MODE_D20 && s_dice_result == 20) { lv_obj_set_style_text_color(res_lbl, lv_color_hex(0x00E676), 0); } else if (s_dice_mode == DICE_MODE_D20 && s_dice_result == 1) { lv_obj_set_style_text_color(res_lbl, lv_color_hex(0xFF1744), 0); } else { lv_obj_set_style_text_color(res_lbl, lv_color_hex(0x00E5FF), 0); } } } lv_obj_align(res_lbl, LV_ALIGN_CENTER, 0, 0); /* Status descriptor under result */ if (s_dice_rolled && s_dice_mode == DICE_MODE_D20) { lv_obj_t *sub = lv_label_create(rbox); if (s_dice_result == 20) { lv_label_set_text(sub, "NATURAL 20!"); lv_obj_set_style_text_color(sub, lv_color_hex(0x00E676), 0); } else if (s_dice_result == 1) { lv_label_set_text(sub, "CRITICAL MISS!"); lv_obj_set_style_text_color(sub, lv_color_hex(0xFF1744), 0); } else { lv_label_set_text(sub, "ROLL RESULT"); lv_obj_set_style_text_color(sub, lv_color_hex(COLOR_AMBER), 0); } lv_obj_set_style_text_font(sub, &lv_font_montserrat_14, 0); lv_obj_align(sub, LV_ALIGN_BOTTOM_MID, 0, -10); } /* Bottom prompt */ lv_obj_t *hint = lv_label_create(lv_scr_act()); lv_label_set_text(hint, "ROTATE: DIE • CLICK: ROLL"); lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8); lvgl_port_unlock(); } static void roll_current_dice(void) { /* Quick 3-tick animation */ for (int i = 0; i < 3; i++) { buzzer_chirp(700 + i * 150, 20); vTaskDelay(pdMS_TO_TICKS(40)); } if (s_dice_mode == DICE_MODE_D20) { s_dice_result = (int)(esp_random() % 20) + 1; if (s_dice_result == 20) { /* Natural 20 - Victory Fanfare! */ buzzer_play_sound(BUZZER_SOUND_VICTORY); } else if (s_dice_result == 1) { /* Critical Fail */ buzzer_chirp(220, 150); } else { buzzer_chirp(880, 50); } } else if (s_dice_mode == DICE_MODE_D6) { s_dice_result = (int)(esp_random() % 6) + 1; buzzer_chirp(880, 50); } else { /* Coin */ s_dice_result = (int)(esp_random() % 2); // 0 = Tails, 1 = Heads buzzer_chirp(988, 60); } s_dice_rolled = true; view_dice(); } /* 5. Commander Tax & Turn Tracker */ static void view_commander_tax(void) { lvgl_port_lock(0); ui_clean_screen(); ui_draw_header("COMMANDER TAX", NULL); /* Casts Box */ lv_obj_t *cast_box = lv_obj_create(lv_scr_act()); lv_obj_set_size(cast_box, DISP_W - 24, 100); lv_obj_align(cast_box, LV_ALIGN_TOP_MID, 0, 48); lv_obj_set_style_radius(cast_box, 8, 0); if (!s_tax_edit_turns) { lv_obj_set_style_bg_color(cast_box, lv_color_hex(0x282410), 0); lv_obj_set_style_border_color(cast_box, lv_color_hex(0xFFCC00), 0); lv_obj_set_style_border_width(cast_box, 2, 0); } else { lv_obj_set_style_bg_color(cast_box, lv_color_hex(0x181818), 0); lv_obj_set_style_border_color(cast_box, lv_color_hex(0x333333), 0); lv_obj_set_style_border_width(cast_box, 1, 0); } lv_obj_t *c_lbl = lv_label_create(cast_box); lv_label_set_text(c_lbl, !s_tax_edit_turns ? "> CASTS FROM ZONE <" : "CASTS FROM ZONE"); lv_obj_set_style_text_font(c_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(c_lbl, !s_tax_edit_turns ? lv_color_hex(0xFFCC00) : lv_color_hex(COLOR_AMBER), 0); lv_obj_align(c_lbl, LV_ALIGN_TOP_MID, 0, 0); lv_obj_t *c_val = lv_label_create(cast_box); lv_label_set_text_fmt(c_val, "%d", s_cmd_casts); lv_obj_set_style_text_font(c_val, &lv_font_montserrat_48, 0); lv_obj_set_style_text_color(c_val, lv_color_white(), 0); lv_obj_align(c_val, LV_ALIGN_CENTER, 0, 10); /* Huge Tax Banner */ lv_obj_t *tax_box = lv_obj_create(lv_scr_act()); lv_obj_set_size(tax_box, DISP_W - 24, 52); lv_obj_align(tax_box, LV_ALIGN_TOP_MID, 0, 156); lv_obj_set_style_bg_color(tax_box, lv_color_hex(0x332800), 0); lv_obj_set_style_border_color(tax_box, lv_color_hex(0xFFCC00), 0); lv_obj_set_style_border_width(tax_box, 2, 0); lv_obj_set_style_radius(tax_box, 6, 0); lv_obj_t *tax_val = lv_label_create(tax_box); lv_label_set_text_fmt(tax_val, "TAX: +{%d} MANA", s_cmd_casts * 2); lv_obj_set_style_text_font(tax_val, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(tax_val, lv_color_hex(0xFFCC00), 0); lv_obj_align(tax_val, LV_ALIGN_CENTER, 0, 0); /* Turn Box */ lv_obj_t *turn_box = lv_obj_create(lv_scr_act()); lv_obj_set_size(turn_box, DISP_W - 24, 55); lv_obj_align(turn_box, LV_ALIGN_TOP_MID, 0, 216); lv_obj_set_style_radius(turn_box, 6, 0); if (s_tax_edit_turns) { lv_obj_set_style_bg_color(turn_box, lv_color_hex(0x102528), 0); lv_obj_set_style_border_color(turn_box, lv_color_hex(0x00E5FF), 0); lv_obj_set_style_border_width(turn_box, 2, 0); } else { lv_obj_set_style_bg_color(turn_box, lv_color_hex(0x181818), 0); lv_obj_set_style_border_color(turn_box, lv_color_hex(0x333333), 0); lv_obj_set_style_border_width(turn_box, 1, 0); } lv_obj_t *t_val = lv_label_create(turn_box); lv_label_set_text_fmt(t_val, s_tax_edit_turns ? "> TURN: %d <" : "TURN: %d", s_turn_count); lv_obj_set_style_text_font(t_val, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(t_val, s_tax_edit_turns ? lv_color_hex(0x00E5FF) : lv_color_hex(COLOR_AMBER), 0); lv_obj_align(t_val, LV_ALIGN_CENTER, 0, 0); /* Bottom prompt */ lv_obj_t *hint = lv_label_create(lv_scr_act()); lv_label_set_text(hint, "CLICK: SWITCH • ROTATE: +/-"); lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8); lvgl_port_unlock(); } /* 5. Token & Counter Spawner */ static void view_token_spawner(void) { lvgl_port_lock(0); ui_clean_screen(); ui_draw_header("TOKEN SPAWNER", NULL); /* Carousel indicator: < 1 OF 8 > */ lv_obj_t *idx_lbl = lv_label_create(lv_scr_act()); lv_label_set_text_fmt(idx_lbl, "< %d OF %d >", s_token_idx + 1, TOKEN_COUNT); lv_obj_set_style_text_font(idx_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(idx_lbl, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(idx_lbl, LV_ALIGN_TOP_MID, 0, 34); mtg_token_t *tok = &s_tokens[s_token_idx]; /* Token Card Display Box */ lv_obj_t *card_box = lv_obj_create(lv_scr_act()); lv_obj_set_size(card_box, DISP_W - 24, 155); lv_obj_align(card_box, LV_ALIGN_TOP_MID, 0, 54); lv_obj_set_style_bg_color(card_box, lv_color_hex(0x181818), 0); lv_obj_set_style_border_color(card_box, lv_color_hex(tok->color_hex), 0); lv_obj_set_style_border_width(card_box, 2, 0); lv_obj_set_style_radius(card_box, 8, 0); lv_obj_set_style_pad_all(card_box, 8, 0); lv_obj_set_scrollbar_mode(card_box, LV_SCROLLBAR_MODE_OFF); /* Token Name */ lv_obj_t *name_lbl = lv_label_create(card_box); lv_label_set_text(name_lbl, tok->name); lv_obj_set_style_text_font(name_lbl, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(name_lbl, lv_color_hex(tok->color_hex), 0); lv_obj_align(name_lbl, LV_ALIGN_TOP_LEFT, 0, 0); /* Token Type Line */ lv_obj_t *type_lbl = lv_label_create(card_box); lv_label_set_text(type_lbl, tok->type_line); lv_obj_set_style_text_font(type_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(type_lbl, lv_color_hex(0x00E5FF), 0); lv_obj_align(type_lbl, LV_ALIGN_TOP_LEFT, 0, 26); /* Rules / Oracle text */ lv_obj_t *desc = lv_label_create(card_box); lv_label_set_text(desc, tok->oracle); lv_label_set_long_mode(desc, LV_LABEL_LONG_WRAP); lv_obj_set_width(desc, DISP_W - 44); lv_obj_set_style_text_font(desc, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(desc, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(desc, LV_ALIGN_TOP_LEFT, 0, 48); /* P/T Badge if creature token */ if (tok->pt[0] != '\0') { lv_obj_t *pt_box = lv_obj_create(card_box); lv_obj_set_size(pt_box, 65, 30); lv_obj_align(pt_box, LV_ALIGN_BOTTOM_RIGHT, 0, 0); lv_obj_set_style_bg_color(pt_box, lv_color_hex(0x282828), 0); lv_obj_set_style_border_color(pt_box, lv_color_hex(tok->color_hex), 0); lv_obj_set_style_border_width(pt_box, 1, 0); lv_obj_set_style_radius(pt_box, 4, 0); lv_obj_set_style_pad_all(pt_box, 0, 0); lv_obj_t *pt_lbl = lv_label_create(pt_box); lv_label_set_text(pt_lbl, tok->pt); lv_obj_set_style_text_font(pt_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(pt_lbl, lv_color_white(), 0); lv_obj_align(pt_lbl, LV_ALIGN_CENTER, 0, 0); } /* Quantity Box */ lv_obj_t *q_box = lv_obj_create(lv_scr_act()); lv_obj_set_size(q_box, DISP_W - 24, 52); lv_obj_align(q_box, LV_ALIGN_TOP_MID, 0, 218); lv_obj_set_style_radius(q_box, 6, 0); if (s_token_edit_count) { lv_obj_set_style_bg_color(q_box, lv_color_hex(0x282410), 0); lv_obj_set_style_border_color(q_box, lv_color_hex(0xFFCC00), 0); lv_obj_set_style_border_width(q_box, 2, 0); } else { lv_obj_set_style_bg_color(q_box, lv_color_hex(0x181818), 0); lv_obj_set_style_border_color(q_box, lv_color_hex(0x333333), 0); lv_obj_set_style_border_width(q_box, 1, 0); } lv_obj_t *q_lbl = lv_label_create(q_box); if (s_token_edit_count) { lv_label_set_text_fmt(q_lbl, "> COUNT: %d <", tok->count); lv_obj_set_style_text_color(q_lbl, lv_color_hex(0xFFCC00), 0); } else { lv_label_set_text_fmt(q_lbl, "COUNT: %d", tok->count); lv_obj_set_style_text_color(q_lbl, tok->count > 0 ? lv_color_white() : lv_color_hex(COLOR_AMBER), 0); } lv_obj_set_style_text_font(q_lbl, &lv_font_montserrat_20, 0); lv_obj_align(q_lbl, LV_ALIGN_CENTER, 0, 0); /* Bottom Prompt */ lv_obj_t *hint = lv_label_create(lv_scr_act()); if (s_token_edit_count) { lv_label_set_text(hint, "TURN: +/- • CLICK: DONE"); } else { lv_label_set_text(hint, "TURN: PICK • CLICK: COUNT"); } lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8); lvgl_port_unlock(); } /* 6. Deck & Storage Manager */ static void view_storage(void) { lvgl_port_lock(0); ui_clean_screen(); ui_draw_header("DECK & STORAGE", NULL); size_t total_b = 0, used_b = 0; storage_manager_get_info(&total_b, &used_b); int json_c = 0, jpg_c = 0; storage_manager_count_cached(&json_c, &jpg_c); if (s_storage_confirm_dialog) { /* Confirmation dialog */ lv_obj_t *warn_box = lv_obj_create(lv_scr_act()); lv_obj_set_size(warn_box, DISP_W - 24, 210); lv_obj_align(warn_box, LV_ALIGN_TOP_MID, 0, 48); lv_obj_set_style_bg_color(warn_box, lv_color_hex(0x281010), 0); lv_obj_set_style_border_color(warn_box, lv_color_hex(0xFF1744), 0); lv_obj_set_style_border_width(warn_box, 2, 0); lv_obj_set_style_radius(warn_box, 8, 0); lv_obj_t *wtitle = lv_label_create(warn_box); lv_label_set_text(wtitle, "CLEAR ALL CARDS?"); lv_obj_set_style_text_font(wtitle, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(wtitle, lv_color_hex(0xFF1744), 0); lv_obj_align(wtitle, LV_ALIGN_TOP_MID, 0, 8); lv_obj_t *wdesc = lv_label_create(warn_box); lv_label_set_text(wdesc, "This wipes all cached\ncard art & data for\na fresh new deck."); lv_obj_set_style_text_font(wdesc, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(wdesc, lv_color_white(), 0); lv_obj_align(wdesc, LV_ALIGN_TOP_MID, 0, 38); /* Confirm button */ lv_obj_t *c_btn = lv_obj_create(warn_box); lv_obj_set_size(c_btn, DISP_W - 56, 38); lv_obj_align(c_btn, LV_ALIGN_TOP_MID, 0, 95); lv_obj_set_style_radius(c_btn, 4, 0); if (s_storage_option_idx == 0) { lv_obj_set_style_bg_color(c_btn, lv_color_hex(0xFF1744), 0); lv_obj_set_style_border_color(c_btn, lv_color_white(), 0); lv_obj_set_style_border_width(c_btn, 2, 0); } else { lv_obj_set_style_bg_color(c_btn, lv_color_hex(0x401010), 0); lv_obj_set_style_border_width(c_btn, 0, 0); } lv_obj_t *c_lbl = lv_label_create(c_btn); lv_label_set_text(c_lbl, s_storage_option_idx == 0 ? "> CONFIRM WIPE <" : "CONFIRM WIPE"); lv_obj_set_style_text_font(c_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(c_lbl, lv_color_white(), 0); lv_obj_align(c_lbl, LV_ALIGN_CENTER, 0, 0); /* Cancel button */ lv_obj_t *cancel_btn = lv_obj_create(warn_box); lv_obj_set_size(cancel_btn, DISP_W - 56, 38); lv_obj_align(cancel_btn, LV_ALIGN_TOP_MID, 0, 142); lv_obj_set_style_radius(cancel_btn, 4, 0); if (s_storage_option_idx == 1) { lv_obj_set_style_bg_color(cancel_btn, lv_color_hex(0xFFCC00), 0); lv_obj_set_style_border_color(cancel_btn, lv_color_white(), 0); lv_obj_set_style_border_width(cancel_btn, 2, 0); } else { lv_obj_set_style_bg_color(cancel_btn, lv_color_hex(0x303030), 0); lv_obj_set_style_border_width(cancel_btn, 0, 0); } lv_obj_t *cancel_lbl = lv_label_create(cancel_btn); lv_label_set_text(cancel_lbl, s_storage_option_idx == 1 ? "> CANCEL <" : "CANCEL"); lv_obj_set_style_text_font(cancel_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(cancel_lbl, s_storage_option_idx == 1 ? lv_color_black() : lv_color_white(), 0); lv_obj_align(cancel_lbl, LV_ALIGN_CENTER, 0, 0); } else { /* Status Card */ lv_obj_t *stat_box = lv_obj_create(lv_scr_act()); lv_obj_set_size(stat_box, DISP_W - 24, 136); lv_obj_align(stat_box, LV_ALIGN_TOP_MID, 0, 44); lv_obj_set_style_bg_color(stat_box, lv_color_hex(0x181818), 0); lv_obj_set_style_border_color(stat_box, lv_color_hex(0x333333), 0); lv_obj_set_style_border_width(stat_box, 1, 0); lv_obj_set_style_radius(stat_box, 6, 0); lv_obj_set_style_pad_all(stat_box, 8, 0); lv_obj_set_scrollbar_mode(stat_box, LV_SCROLLBAR_MODE_OFF); struct { const char *key; char val[32]; } stat_rows[5]; snprintf(stat_rows[0].val, sizeof(stat_rows[0].val), "%d", json_c); stat_rows[0].key = "CACHED CARDS:"; snprintf(stat_rows[1].val, sizeof(stat_rows[1].val), "%d", jpg_c); stat_rows[1].key = "ART CROPS:"; snprintf(stat_rows[2].val, sizeof(stat_rows[2].val), "%d / %d KB", (int)(used_b / 1024), (int)(total_b / 1024)); stat_rows[2].key = "STORAGE USED:"; int percent = total_b > 0 ? (int)((used_b * 100) / total_b) : 0; snprintf(stat_rows[3].val, sizeof(stat_rows[3].val), "%d%%", percent); stat_rows[3].key = "PERCENT USED:"; snprintf(stat_rows[4].val, sizeof(stat_rows[4].val), "~100 (EDH)"); stat_rows[4].key = "DECK CAPACITY:"; for (int i = 0; i < 5; i++) { int y_pos = (i < 2) ? (i * 20) : (i * 20 + 8); lv_obj_t *k = lv_label_create(stat_box); lv_label_set_text(k, stat_rows[i].key); lv_obj_set_style_text_font(k, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(k, lv_color_hex(0x00E5FF), 0); lv_obj_align(k, LV_ALIGN_TOP_LEFT, 0, y_pos); lv_obj_t *v = lv_label_create(stat_box); lv_label_set_text(v, stat_rows[i].val); lv_obj_set_width(v, 90); lv_label_set_long_mode(v, LV_LABEL_LONG_DOT); lv_obj_set_style_text_font(v, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(v, lv_color_hex(COLOR_AMBER), 0); lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0); lv_obj_align(v, LV_ALIGN_TOP_RIGHT, 0, y_pos); } /* Action 0: Clear Cards Button */ lv_obj_t *btn0 = lv_obj_create(lv_scr_act()); lv_obj_set_size(btn0, DISP_W - 24, 42); lv_obj_align(btn0, LV_ALIGN_TOP_MID, 0, 192); lv_obj_set_style_radius(btn0, 6, 0); if (s_storage_option_idx == 0) { lv_obj_set_style_bg_color(btn0, lv_color_hex(0xFF1744), 0); lv_obj_set_style_border_color(btn0, lv_color_white(), 0); lv_obj_set_style_border_width(btn0, 2, 0); } else { lv_obj_set_style_bg_color(btn0, lv_color_hex(0x301515), 0); lv_obj_set_style_border_color(btn0, lv_color_hex(0x502020), 0); lv_obj_set_style_border_width(btn0, 1, 0); } lv_obj_t *b0_lbl = lv_label_create(btn0); lv_label_set_text(b0_lbl, s_storage_option_idx == 0 ? "> CLEAR FOR NEW DECK <" : "CLEAR FOR NEW DECK"); lv_obj_set_style_text_font(b0_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(b0_lbl, lv_color_white(), 0); lv_obj_align(b0_lbl, LV_ALIGN_CENTER, 0, 0); /* Action 1: Back to Menu Button */ lv_obj_t *btn1 = lv_obj_create(lv_scr_act()); lv_obj_set_size(btn1, DISP_W - 24, 38); lv_obj_align(btn1, LV_ALIGN_TOP_MID, 0, 242); lv_obj_set_style_radius(btn1, 6, 0); if (s_storage_option_idx == 1) { lv_obj_set_style_bg_color(btn1, lv_color_hex(0xFFCC00), 0); lv_obj_set_style_border_color(btn1, lv_color_white(), 0); lv_obj_set_style_border_width(btn1, 2, 0); } else { lv_obj_set_style_bg_color(btn1, lv_color_hex(0x202020), 0); lv_obj_set_style_border_color(btn1, lv_color_hex(0x333333), 0); lv_obj_set_style_border_width(btn1, 1, 0); } lv_obj_t *b1_lbl = lv_label_create(btn1); lv_label_set_text(b1_lbl, s_storage_option_idx == 1 ? "> BACK TO MENU <" : "BACK TO MENU"); lv_obj_set_style_text_font(b1_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(b1_lbl, s_storage_option_idx == 1 ? lv_color_black() : lv_color_white(), 0); lv_obj_align(b1_lbl, LV_ALIGN_CENTER, 0, 0); } lv_obj_t *hint = lv_label_create(lv_scr_act()); lv_label_set_text(hint, "ROTATE: OPTION • CLICK: SELECT"); lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8); lvgl_port_unlock(); } /* 7. Device & Wi-Fi Info */ static void view_device_info(void) { lvgl_port_lock(0); ui_clean_screen(); ui_draw_header("DEVICE INFO", NULL); lv_obj_t *box = lv_obj_create(lv_scr_act()); lv_obj_set_size(box, DISP_W - 24, 220); lv_obj_align(box, LV_ALIGN_TOP_MID, 0, 48); lv_obj_set_style_bg_color(box, lv_color_hex(0x181818), 0); lv_obj_set_style_border_color(box, lv_color_hex(0x333333), 0); lv_obj_set_style_radius(box, 6, 0); lv_obj_set_style_pad_all(box, 8, 0); size_t total_b = 0, used_b = 0; storage_manager_get_info(&total_b, &used_b); int json_c = 0, jpg_c = 0; storage_manager_count_cached(&json_c, &jpg_c); uint32_t free_heap = esp_get_free_heap_size(); const char *ssid = wifi_manager_get_connected_ssid(); int rssi = wifi_manager_get_rssi(); char wifi_line[64]; if (wifi_manager_connected() && ssid && strlen(ssid) > 0) { snprintf(wifi_line, sizeof(wifi_line), "%s (%ddB)", ssid, rssi); } else { snprintf(wifi_line, sizeof(wifi_line), "Disconnected"); } struct { const char *key; char val[64]; } rows[10]; snprintf(rows[0].val, sizeof(rows[0].val), "%s", ota_manager_get_current_version()); rows[0].key = "VERSION:"; snprintf(rows[1].val, sizeof(rows[1].val), "%s", wifi_line); rows[1].key = "WI-FI:"; snprintf(rows[2].val, sizeof(rows[2].val), "%s", wifi_manager_get_ip()); rows[2].key = "IP ADDR:"; snprintf(rows[3].val, sizeof(rows[3].val), "192.168.4.1"); rows[3].key = "HOTSPOT:"; snprintf(rows[4].val, sizeof(rows[4].val), "%d / %d KB", (int)(used_b / 1024), (int)(total_b / 1024)); rows[4].key = "STORAGE:"; snprintf(rows[5].val, sizeof(rows[5].val), "%d cached", json_c); rows[5].key = "CARDS:"; snprintf(rows[6].val, sizeof(rows[6].val), "%d KB", (int)(free_heap / 1024)); rows[6].key = "FREE RAM:"; snprintf(rows[7].val, sizeof(rows[7].val), "%d steps", (int)encoder_get_steps_per_detent()); rows[7].key = "ENCODER:"; snprintf(rows[8].val, sizeof(rows[8].val), "ESP32+ST7789"); rows[8].key = "HARDWARE:"; snprintf(rows[9].val, sizeof(rows[9].val), "MFRC522"); rows[9].key = "RFID:"; for (int i = 0; i < 10; i++) { int y_pos = i * 20; lv_obj_t *k = lv_label_create(box); lv_label_set_text(k, rows[i].key); lv_obj_set_style_text_font(k, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(k, lv_color_hex(0x00E5FF), 0); lv_obj_align(k, LV_ALIGN_TOP_LEFT, 0, y_pos); lv_obj_t *v = lv_label_create(box); lv_label_set_text(v, rows[i].val); lv_obj_set_width(v, 120); lv_label_set_long_mode(v, LV_LABEL_LONG_DOT); lv_obj_set_style_text_font(v, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(v, lv_color_hex(COLOR_AMBER), 0); lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0); lv_obj_align(v, LV_ALIGN_TOP_RIGHT, 0, y_pos); } lv_obj_t *hint = lv_label_create(lv_scr_act()); lv_label_set_text(hint, "CLICK: MENU"); lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8); lvgl_port_unlock(); } /* 8. Firmware OTA Update Screen */ static ota_status_t s_last_ota_rendered_st = OTA_STATUS_IDLE; static int s_last_ota_percent = -1; static void update_ota_progress(int percent, size_t rb, size_t tb) { lvgl_port_lock(0); if (s_ota_bar && s_ota_pct_lbl && s_ota_bytes_lbl) { lv_bar_set_value(s_ota_bar, percent, LV_ANIM_OFF); lv_label_set_text_fmt(s_ota_pct_lbl, "%d%%", percent); lv_label_set_text_fmt(s_ota_bytes_lbl, "%d / %d KB", (int)(rb / 1024), (int)(tb / 1024)); } lvgl_port_unlock(); } static void view_ota(void) { lvgl_port_lock(0); ui_clean_screen(); ota_status_t st = ota_manager_get_status(); s_last_ota_rendered_st = st; ui_draw_header("FIRMWARE UPDATE", "GITEA OTA"); switch (st) { case OTA_STATUS_IDLE: case OTA_STATUS_CHECKING: { lv_obj_t *box = lv_obj_create(lv_scr_act()); lv_obj_set_size(box, DISP_W - 24, 180); lv_obj_align(box, LV_ALIGN_CENTER, 0, -10); lv_obj_set_style_bg_color(box, lv_color_hex(0x1C1C1C), 0); lv_obj_set_style_border_color(box, lv_color_hex(0x00E5FF), 0); lv_obj_set_style_border_width(box, 2, 0); lv_obj_set_style_radius(box, 8, 0); lv_obj_t *lbl = lv_label_create(box); lv_label_set_text(lbl, "CHECKING GITEA...\n\nConnecting to\nreleases API"); lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(lbl, lv_color_hex(0x00E5FF), 0); lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(lbl, LV_ALIGN_CENTER, 0, 0); lv_obj_t *hint = lv_label_create(lv_scr_act()); lv_label_set_text(hint, "HOLD BUTTON: MAIN MENU"); lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8); break; } case OTA_STATUS_UPDATE_AVAILABLE: { const ota_release_info_t *rel = ota_manager_get_release_info(); const char *cur_ver = ota_manager_get_current_version(); lv_obj_t *box = lv_obj_create(lv_scr_act()); lv_obj_set_size(box, DISP_W - 24, 200); lv_obj_align(box, LV_ALIGN_CENTER, 0, -15); lv_obj_set_style_bg_color(box, lv_color_hex(0x102818), 0); lv_obj_set_style_border_color(box, lv_color_hex(0x00E676), 0); lv_obj_set_style_border_width(box, 2, 0); lv_obj_set_style_radius(box, 8, 0); lv_obj_t *t = lv_label_create(box); lv_label_set_text(t, "NEW VERSION AVAILABLE!"); lv_obj_set_style_text_font(t, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(t, lv_color_hex(0x00E676), 0); lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 8); char target_str[32]; if (strlen(rel->remote_hash) >= 7) { snprintf(target_str, sizeof(target_str), "%.8s", rel->remote_hash); } else { snprintf(target_str, sizeof(target_str), "%s", rel->tag_name); } lv_obj_t *v = lv_label_create(box); lv_label_set_text_fmt(v, "NEW: %s", target_str); lv_obj_set_style_text_font(v, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(v, lv_color_hex(0xFFCC00), 0); lv_obj_align(v, LV_ALIGN_TOP_MID, 0, 38); const char *cur_sha = ota_manager_get_running_sha256(); char cur_str[32]; if (strlen(cur_sha) >= 7) { snprintf(cur_str, sizeof(cur_str), "%.8s", cur_sha); } else { snprintf(cur_str, sizeof(cur_str), "%.8s", cur_ver); } lv_obj_t *c = lv_label_create(box); lv_label_set_text_fmt(c, "Current: %s", cur_str); lv_obj_set_style_text_font(c, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(c, lv_color_white(), 0); lv_obj_align(c, LV_ALIGN_TOP_MID, 0, 70); lv_obj_t *s = lv_label_create(box); if (rel->binary_size > 0) { lv_label_set_text_fmt(s, "Size: %d KB", (int)(rel->binary_size / 1024)); } else { lv_label_set_text(s, "Ready to stream"); } lv_obj_set_style_text_font(s, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(s, lv_color_hex(0x888888), 0); lv_obj_align(s, LV_ALIGN_TOP_MID, 0, 95); lv_obj_t *act = lv_label_create(box); lv_label_set_text(act, "> CLICK: INSTALL NOW <"); lv_obj_set_style_text_font(act, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(act, lv_color_hex(0x00E676), 0); lv_obj_align(act, LV_ALIGN_BOTTOM_MID, 0, -8); lv_obj_t *hint = lv_label_create(lv_scr_act()); lv_label_set_text(hint, "ROTATE / HOLD: SKIP FOR NOW"); lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8); break; } case OTA_STATUS_UP_TO_DATE: { const char *cur_ver = ota_manager_get_current_version(); const char *cur_sha = ota_manager_get_running_sha256(); char disp_ver[32]; if (strlen(cur_sha) >= 7) { snprintf(disp_ver, sizeof(disp_ver), "%.8s", cur_sha); } else { snprintf(disp_ver, sizeof(disp_ver), "%s", cur_ver); } lv_obj_t *box = lv_obj_create(lv_scr_act()); lv_obj_set_size(box, DISP_W - 24, 180); lv_obj_align(box, LV_ALIGN_CENTER, 0, -10); lv_obj_set_style_bg_color(box, lv_color_hex(0x1C1C1C), 0); lv_obj_set_style_border_color(box, lv_color_hex(0x333333), 0); lv_obj_set_style_border_width(box, 1, 0); lv_obj_set_style_radius(box, 8, 0); lv_obj_t *t = lv_label_create(box); lv_label_set_text(t, "UP TO DATE"); lv_obj_set_style_text_font(t, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(t, lv_color_hex(0x00E676), 0); lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 15); lv_obj_t *v = lv_label_create(box); lv_label_set_text_fmt(v, "Hash: %s\n\nNo newer releases\nfound on Gitea.", disp_ver); lv_obj_set_style_text_font(v, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(v, lv_color_white(), 0); lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(v, LV_ALIGN_CENTER, 0, 15); lv_obj_t *hint = lv_label_create(lv_scr_act()); lv_label_set_text(hint, "CLICK: MAIN MENU"); lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8); break; } case OTA_STATUS_DOWNLOADING: { int percent = ota_manager_get_progress(); s_last_ota_percent = percent; size_t rb = 0, tb = 0; ota_manager_get_bytes(&rb, &tb); lv_obj_t *box = lv_obj_create(lv_scr_act()); lv_obj_set_size(box, DISP_W - 24, 200); lv_obj_align(box, LV_ALIGN_CENTER, 0, -10); lv_obj_set_style_bg_color(box, lv_color_hex(0x181828), 0); lv_obj_set_style_border_color(box, lv_color_hex(0x00E5FF), 0); lv_obj_set_style_border_width(box, 2, 0); lv_obj_set_style_radius(box, 8, 0); lv_obj_t *t = lv_label_create(box); lv_label_set_text(t, "FLASHING FIRMWARE..."); lv_obj_set_style_text_font(t, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(t, lv_color_hex(0x00E5FF), 0); lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 10); s_ota_pct_lbl = lv_label_create(box); lv_label_set_text_fmt(s_ota_pct_lbl, "%d%%", percent); lv_obj_set_style_text_font(s_ota_pct_lbl, &lv_font_montserrat_48, 0); lv_obj_set_style_text_color(s_ota_pct_lbl, lv_color_hex(0xFFCC00), 0); lv_obj_align(s_ota_pct_lbl, LV_ALIGN_CENTER, 0, -15); s_ota_bar = lv_bar_create(box); lv_obj_set_size(s_ota_bar, DISP_W - 60, 14); lv_obj_align(s_ota_bar, LV_ALIGN_CENTER, 0, 35); lv_bar_set_range(s_ota_bar, 0, 100); lv_bar_set_value(s_ota_bar, percent, LV_ANIM_OFF); lv_obj_set_style_bg_color(s_ota_bar, lv_color_hex(0x333333), 0); lv_obj_set_style_bg_color(s_ota_bar, lv_color_hex(0x00E676), LV_PART_INDICATOR); s_ota_bytes_lbl = lv_label_create(box); lv_label_set_text_fmt(s_ota_bytes_lbl, "%d / %d KB", (int)(rb / 1024), (int)(tb / 1024)); lv_obj_set_style_text_font(s_ota_bytes_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(s_ota_bytes_lbl, lv_color_hex(0xAAAAAA), 0); lv_obj_align(s_ota_bytes_lbl, LV_ALIGN_BOTTOM_MID, 0, -8); lv_obj_t *warn = lv_label_create(lv_scr_act()); lv_label_set_text(warn, "DO NOT TURN OFF POWER"); lv_obj_set_style_text_font(warn, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(warn, lv_color_hex(0xFF1744), 0); lv_obj_align(warn, LV_ALIGN_BOTTOM_MID, 0, -8); break; } case OTA_STATUS_SUCCESS_REBOOTING: { lv_obj_t *box = lv_obj_create(lv_scr_act()); lv_obj_set_size(box, DISP_W - 24, 180); lv_obj_align(box, LV_ALIGN_CENTER, 0, -10); lv_obj_set_style_bg_color(box, lv_color_hex(0x102818), 0); lv_obj_set_style_border_color(box, lv_color_hex(0x00E676), 0); lv_obj_set_style_border_width(box, 2, 0); lv_obj_set_style_radius(box, 8, 0); lv_obj_t *t = lv_label_create(box); lv_label_set_text(t, "UPDATE SUCCESSFUL!"); lv_obj_set_style_text_font(t, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(t, lv_color_hex(0x00E676), 0); lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 20); lv_obj_t *m = lv_label_create(box); lv_label_set_text(m, "New firmware verified.\n\nRebooting device..."); lv_obj_set_style_text_font(m, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(m, lv_color_white(), 0); lv_obj_set_style_text_align(m, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(m, LV_ALIGN_CENTER, 0, 25); break; } case OTA_STATUS_FAILED: default: { lv_obj_t *box = lv_obj_create(lv_scr_act()); lv_obj_set_size(box, DISP_W - 24, 180); lv_obj_align(box, LV_ALIGN_CENTER, 0, -10); lv_obj_set_style_bg_color(box, lv_color_hex(0x281010), 0); lv_obj_set_style_border_color(box, lv_color_hex(0xFF1744), 0); lv_obj_set_style_border_width(box, 2, 0); lv_obj_set_style_radius(box, 8, 0); lv_obj_t *t = lv_label_create(box); lv_label_set_text(t, "UPDATE FAILED"); lv_obj_set_style_text_font(t, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(t, lv_color_hex(0xFF1744), 0); lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 15); lv_obj_t *msg = lv_label_create(box); lv_label_set_text_fmt(msg, "%s\n\nExisting firmware intact.", ota_manager_get_message()); lv_obj_set_style_text_font(msg, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(msg, lv_color_white(), 0); lv_obj_set_style_text_align(msg, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(msg, LV_ALIGN_CENTER, 0, 20); lv_obj_t *hint = lv_label_create(lv_scr_act()); lv_label_set_text(hint, "CLICK: MAIN MENU"); lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8); break; } } lvgl_port_unlock(); } /* 8. Card View Screen */ static void view_card(void) { char json_path[72]; snprintf(json_path, sizeof(json_path), STORAGE_CARDS_DIR "/%s.json", s_uid); char *buf = NULL; size_t len = 0; if (storage_read_text(json_path, &buf, &len) != ESP_OK || buf == NULL) { free(buf); lvgl_port_lock(0); ui_clean_screen(); lv_obj_t *err = lv_label_create(lv_scr_act()); lv_label_set_text(err, "CARD DATA MISSING"); lv_obj_set_style_text_font(err, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(err, lv_color_hex(0xFF1744), 0); lv_obj_align(err, LV_ALIGN_CENTER, 0, 0); lvgl_port_unlock(); return; } cJSON *root = cJSON_Parse(buf); free(buf); if (root == NULL) { remove(json_path); if (s_card_name[0] != '\0' && s_uid[0] != '\0') { scryfall_fetch_request(s_card_name, s_uid); } lvgl_port_lock(0); ui_clean_screen(); lv_obj_t *err = lv_label_create(lv_scr_act()); lv_label_set_text(err, "RE-FETCHING CARD..."); lv_obj_set_style_text_font(err, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(err, lv_color_hex(0xFFCC00), 0); lv_obj_align(err, LV_ALIGN_CENTER, 0, 0); lvgl_port_unlock(); return; } const char *name = get_json_str(root, "name", s_card_name[0] ? s_card_name : "?"); if (s_card_name[0] != '\0' && name[0] != '\0' && strcasecmp(name, s_card_name) != 0) { /* Stale cache entry from a previous card! Purge and fetch fresh */ ESP_LOGW(TAG, "Cached card '%s' != mapped card '%s' for %s, re-fetching...", name, s_card_name, s_uid); cJSON_Delete(root); remove(json_path); char jpg_path[72]; snprintf(jpg_path, sizeof(jpg_path), STORAGE_CARDS_DIR "/%s.jpg", s_uid); remove(jpg_path); s_waiting_fetch = true; view_fetching(s_card_name); scryfall_fetch_request(s_card_name, s_uid); return; } const char *mana = get_json_str(root, "mana_cost", ""); const char *type = get_json_str(root, "type", ""); const char *rarity = get_json_str(root, "rarity", ""); const char *power = get_json_str(root, "power", ""); const char *tough = get_json_str(root, "toughness", ""); const char *loyalty = get_json_str(root, "loyalty", ""); const char *oracle = get_json_str(root, "oracle", ""); const char *price = get_json_str(root, "price_usd", ""); if (strstr(type, "Legendary") != NULL || strcasecmp(rarity, "mythic") == 0) { buzzer_play_sound(BUZZER_SOUND_LEGENDARY); } bool has_art = load_art_crop(s_uid); lvgl_port_lock(0); ui_clean_screen(); /* Card Box Container */ lv_obj_t *card_box = lv_obj_create(lv_scr_act()); lv_obj_set_size(card_box, DISP_W - 12, DISP_H - 12); lv_obj_align(card_box, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_color(card_box, lv_color_hex(0x181818), 0); lv_obj_set_style_border_color(card_box, lv_color_hex(0xFFCC00), 0); lv_obj_set_style_border_width(card_box, 2, 0); lv_obj_set_style_radius(card_box, 6, 0); lv_obj_set_style_pad_all(card_box, 6, 0); lv_obj_set_scrollbar_mode(card_box, LV_SCROLLBAR_MODE_OFF); /* Name */ lv_obj_t *name_lbl = lv_label_create(card_box); lv_label_set_text(name_lbl, name); lv_obj_set_style_text_font(name_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(name_lbl, lv_color_hex(0xFFCC00), 0); int name_max_w = (mana[0] != '\0') ? (DISP_W - 36 - 60) : (DISP_W - 36); lv_obj_set_width(name_lbl, name_max_w); lv_label_set_long_mode(name_lbl, LV_LABEL_LONG_DOT); lv_obj_align(name_lbl, LV_ALIGN_TOP_LEFT, 0, 0); /* Mana */ if (mana[0]) { lv_obj_t *mana_lbl = lv_label_create(card_box); lv_label_set_text(mana_lbl, mana); lv_obj_set_style_text_font(mana_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(mana_lbl, lv_color_hex(0x00E676), 0); lv_obj_align(mana_lbl, LV_ALIGN_TOP_RIGHT, 0, 0); } int cur_y = 20; /* Art Crop Image */ if (has_art) { lv_obj_t *img = lv_img_create(card_box); lv_img_set_src(img, &s_art_img_dsc); lv_obj_align(img, LV_ALIGN_TOP_MID, 0, cur_y); lv_obj_set_style_border_color(img, lv_color_hex(0x8B6508), 0); lv_obj_set_style_border_width(img, 1, 0); lv_obj_set_style_radius(img, 3, 0); cur_y += s_art_img_dsc.header.h + 4; } /* Type */ lv_obj_t *type_lbl = lv_label_create(card_box); lv_label_set_text(type_lbl, type); lv_obj_set_style_text_font(type_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(type_lbl, lv_color_white(), 0); lv_obj_set_width(type_lbl, DISP_W - 28); lv_label_set_long_mode(type_lbl, LV_LABEL_LONG_DOT); lv_obj_align(type_lbl, LV_ALIGN_TOP_LEFT, 0, cur_y); cur_y += 18; /* Oracle text */ if (oracle[0]) { lv_obj_t *oracle_lbl = lv_label_create(card_box); lv_label_set_text(oracle_lbl, oracle); lv_label_set_long_mode(oracle_lbl, LV_LABEL_LONG_WRAP); lv_obj_set_width(oracle_lbl, DISP_W - 32); lv_obj_set_style_text_font(oracle_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(oracle_lbl, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(oracle_lbl, LV_ALIGN_TOP_LEFT, 0, cur_y); } /* Bottom: P/T, Loyalty, and Price */ if (power[0] || tough[0]) { lv_obj_t *pt_lbl = lv_label_create(card_box); lv_label_set_text_fmt(pt_lbl, "%s / %s", power, tough); lv_obj_set_style_text_font(pt_lbl, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(pt_lbl, lv_color_hex(0x00E5FF), 0); lv_obj_align(pt_lbl, LV_ALIGN_BOTTOM_RIGHT, 0, 0); } else if (loyalty[0]) { lv_obj_t *loy_lbl = lv_label_create(card_box); lv_label_set_text_fmt(loy_lbl, "[%s]", loyalty); lv_obj_set_style_text_font(loy_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(loy_lbl, lv_color_hex(0x00E5FF), 0); lv_obj_align(loy_lbl, LV_ALIGN_BOTTOM_RIGHT, 0, 0); } if (price[0]) { lv_obj_t *pr_lbl = lv_label_create(card_box); lv_label_set_text_fmt(pr_lbl, "$%s", price); lv_obj_set_style_text_font(pr_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(pr_lbl, lv_color_hex(0xE040FB), 0); lv_obj_align(pr_lbl, LV_ALIGN_BOTTOM_LEFT, 0, 0); } lvgl_port_unlock(); cJSON_Delete(root); } static void view_unmapped(const char *uid) { lvgl_port_lock(0); ui_clean_screen(); lv_obj_t *title = lv_label_create(lv_scr_act()); lv_label_set_text(title, "UNMAPPED CARD"); lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(title, lv_color_hex(0xFFCC00), 0); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 30); lv_obj_t *u_lbl = lv_label_create(lv_scr_act()); lv_label_set_text_fmt(u_lbl, "UID: %s", uid); lv_obj_set_style_text_font(u_lbl, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(u_lbl, lv_color_hex(0x00E5FF), 0); lv_obj_align(u_lbl, LV_ALIGN_TOP_MID, 0, 70); lv_obj_t *sub = lv_label_create(lv_scr_act()); lv_obj_set_style_text_font(sub, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(sub, lv_color_white(), 0); lv_obj_t *ip = lv_label_create(lv_scr_act()); lv_obj_set_style_text_font(ip, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(ip, lv_color_hex(0x00E676), 0); if (wifi_manager_connected()) { lv_label_set_text(sub, "PAIR VIA WI-FI:"); lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 105); lv_label_set_text_fmt(ip, "http://%s", wifi_manager_get_ip()); lv_obj_align(ip, LV_ALIGN_TOP_MID, 0, 135); lv_obj_t *alt = lv_label_create(lv_scr_act()); lv_label_set_text(alt, "or Hotspot: MTG-Companion\n(http://192.168.4.1)"); lv_obj_set_style_text_font(alt, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(alt, lv_color_hex(0x888888), 0); lv_obj_set_style_text_align(alt, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(alt, LV_ALIGN_TOP_MID, 0, 175); } else { lv_label_set_text(sub, "PAIR ON HOTSPOT:"); lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 105); lv_obj_t *ssid = lv_label_create(lv_scr_act()); lv_label_set_text(ssid, "SSID: MTG-Companion"); lv_obj_set_style_text_font(ssid, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(ssid, lv_color_hex(0xFFCC00), 0); lv_obj_align(ssid, LV_ALIGN_TOP_MID, 0, 135); lv_label_set_text(ip, "http://192.168.4.1"); lv_obj_align(ip, LV_ALIGN_TOP_MID, 0, 165); } lv_obj_t *hint = lv_label_create(lv_scr_act()); lv_label_set_text(hint, "HOLD BUTTON: MAIN MENU"); lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -10); lvgl_port_unlock(); } static void view_fetching(const char *name) { lvgl_port_lock(0); ui_clean_screen(); lv_obj_t *title = lv_label_create(lv_scr_act()); lv_label_set_text(title, "FETCHING CARD..."); lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(title, lv_color_hex(0xFFCC00), 0); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 50); lv_obj_t *cname = lv_label_create(lv_scr_act()); lv_label_set_text(cname, name); lv_label_set_long_mode(cname, LV_LABEL_LONG_DOT); lv_obj_set_width(cname, DISP_W - 24); lv_obj_set_style_text_align(cname, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_font(cname, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(cname, lv_color_white(), 0); lv_obj_align(cname, LV_ALIGN_CENTER, 0, -20); lv_obj_t *sub = lv_label_create(lv_scr_act()); lv_label_set_text(sub, "Downloading Scryfall data\nand art illustration..."); lv_obj_set_style_text_font(sub, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(sub, lv_color_hex(COLOR_AMBER), 0); lv_obj_set_style_text_align(sub, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(sub, LV_ALIGN_CENTER, 0, 20); lvgl_port_unlock(); } static void handle_new_card(const char *uid) { snprintf(s_uid, sizeof(s_uid), "%s", uid); rfid_manager_consume_card(); char *name = NULL; esp_err_t ret = storage_manager_lookup_name(uid, &name); if (ret != ESP_OK || name == NULL) { wifi_manager_set_power_save(false); app_set_view(APP_STATE_REGISTRATION); view_unmapped(uid); buzzer_chirp(392, 80); free(name); return; } snprintf(s_card_name, sizeof(s_card_name), "%s", name); app_set_view(APP_STATE_CARD_VIEW); buzzer_chirp(880, 60); char json_path[72]; snprintf(json_path, sizeof(json_path), STORAGE_CARDS_DIR "/%s.json", uid); bool need_fetch = true; if (storage_file_exists(json_path)) { char *chk = NULL; size_t chk_len = 0; if (storage_read_text(json_path, &chk, &chk_len) == ESP_OK && chk != NULL) { cJSON *cj = cJSON_Parse(chk); if (cj) { cJSON *cn = cJSON_GetObjectItem(cj, "name"); if (cn && cn->valuestring && strcasecmp(cn->valuestring, name) == 0) { need_fetch = false; } cJSON_Delete(cj); } free(chk); } } if (!need_fetch) { view_card(); } else { s_waiting_fetch = true; view_fetching(name); scryfall_fetch_request(name, uid); } free(name); } static void handle_fetch_ready(void) { const char *ready_uid = scryfall_last_result_uid(); if (ready_uid != NULL && strcmp(ready_uid, s_uid) == 0) { bool success = scryfall_last_result_success(); scryfall_consume_result(); s_waiting_fetch = false; if (success) { view_card(); } else { buzzer_chirp(220, 150); lvgl_port_lock(0); ui_clean_screen(); ui_draw_header("FETCH FAILED", NULL); lv_obj_t *box = lv_obj_create(lv_scr_act()); lv_obj_set_size(box, DISP_W - 24, 180); lv_obj_align(box, LV_ALIGN_CENTER, 0, -10); lv_obj_set_style_bg_color(box, lv_color_hex(0x281010), 0); lv_obj_set_style_border_color(box, lv_color_hex(0xFF1744), 0); lv_obj_set_style_border_width(box, 2, 0); lv_obj_set_style_radius(box, 8, 0); lv_obj_set_style_pad_all(box, 8, 0); lv_obj_t *cname = lv_label_create(box); lv_label_set_text(cname, s_card_name); lv_obj_set_style_text_font(cname, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(cname, lv_color_hex(0xFFCC00), 0); lv_label_set_long_mode(cname, LV_LABEL_LONG_DOT); lv_obj_set_width(cname, DISP_W - 44); lv_obj_align(cname, LV_ALIGN_TOP_MID, 0, 10); lv_obj_t *sub = lv_label_create(box); lv_label_set_text(sub, "Could not fetch card from\nScryfall. Check Wi-Fi STA\nor card spelling online.\n\nCLICK: RETRY"); lv_obj_set_style_text_font(sub, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(sub, lv_color_white(), 0); lv_obj_set_style_text_align(sub, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(sub, LV_ALIGN_CENTER, 0, 20); lv_obj_t *hint = lv_label_create(lv_scr_act()); lv_label_set_text(hint, "HOLD: MAIN MENU"); lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_AMBER), 0); lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8); lvgl_port_unlock(); } } else { scryfall_consume_result(); } } /* ------------------------------------------------------------------ */ /* Encoder event dispatcher */ /* ------------------------------------------------------------------ */ static void handle_encoder_click(void) { app_state_t cur = app_get_view(); switch (cur) { case APP_STATE_MENU: buzzer_chirp(988, 40); switch (s_menu_idx) { case 0: app_set_view(APP_STATE_SCANNER); view_scanner(); break; case 1: app_set_view(APP_STATE_LIFE_COUNTER); view_life(); break; case 2: app_set_view(APP_STATE_DICE_ROLLER); view_dice(); break; case 3: app_set_view(APP_STATE_COMMANDER_TRACKER); view_commander_tax(); break; case 4: app_set_view(APP_STATE_TOKEN_SPAWNER); s_token_edit_count = false; view_token_spawner(); break; case 5: app_set_view(APP_STATE_STORAGE_MANAGER); s_storage_confirm_dialog = false; s_storage_option_idx = 0; view_storage(); break; case 6: app_set_view(APP_STATE_DEVICE_INFO); view_device_info(); break; case 7: app_set_view(APP_STATE_OTA_UPDATE); buzzer_chirp(988, 40); ota_manager_check_update_async(); view_ota(); break; default: break; } break; case APP_STATE_OTA_UPDATE: { ota_status_t st = ota_manager_get_status(); if (st == OTA_STATUS_UPDATE_AVAILABLE) { buzzer_chirp(1046, 100); ota_manager_start_update_async(); view_ota(); } else if (st == OTA_STATUS_UP_TO_DATE || st == OTA_STATUS_FAILED) { ota_manager_reset(); app_set_view(APP_STATE_MENU); view_menu(); } break; } case APP_STATE_LIFE_COUNTER: s_life_mode = (s_life_mode + 1) % 3; buzzer_chirp(880, 30); view_life(); break; case APP_STATE_DICE_ROLLER: roll_current_dice(); break; case APP_STATE_COMMANDER_TRACKER: s_tax_edit_turns = !s_tax_edit_turns; buzzer_chirp(880, 30); view_commander_tax(); break; case APP_STATE_TOKEN_SPAWNER: s_token_edit_count = !s_token_edit_count; buzzer_chirp(880, 30); view_token_spawner(); break; case APP_STATE_STORAGE_MANAGER: if (!s_storage_confirm_dialog) { if (s_storage_option_idx == 0) { s_storage_confirm_dialog = true; buzzer_chirp(587, 60); view_storage(); } else { app_set_view(APP_STATE_MENU); view_menu(); } } else { if (s_storage_option_idx == 0) { storage_manager_clear_all_cards(true); s_card_name[0] = '\0'; s_uid[0] = '\0'; s_waiting_fetch = false; buzzer_chirp(1046, 100); s_storage_confirm_dialog = false; view_storage(); } else { s_storage_confirm_dialog = false; s_storage_option_idx = 0; view_storage(); } } break; case APP_STATE_DEVICE_INFO: encoder_set_steps_per_detent(4); buzzer_chirp(880, 40); app_set_view(APP_STATE_MENU); view_menu(); break; case APP_STATE_SCANNER: case APP_STATE_CARD_VIEW: case APP_STATE_REGISTRATION: if (cur == APP_STATE_REGISTRATION) { wifi_manager_set_power_save(true); } if (cur == APP_STATE_CARD_VIEW && s_card_name[0] != '\0' && !s_waiting_fetch) { char jp[72]; snprintf(jp, sizeof(jp), STORAGE_CARDS_DIR "/%s.json", s_uid); if (!storage_file_exists(jp)) { /* Retry fetch */ s_waiting_fetch = true; view_fetching(s_card_name); scryfall_fetch_request(s_card_name, s_uid); break; } } app_set_view(APP_STATE_LIFE_COUNTER); view_life(); break; default: break; } } static void handle_encoder_rotation(int32_t delta) { if (delta == 0) return; app_state_t cur = app_get_view(); int32_t dir = (delta > 0) ? 1 : -1; int32_t count = (delta > 0) ? delta : -delta; /* Apply one discrete action per tick */ for (int32_t i = 0; i < count; i++) { switch (cur) { case APP_STATE_MENU: if (dir > 0) { s_menu_idx = (s_menu_idx + 1) % MENU_ITEM_COUNT; } else { s_menu_idx = (s_menu_idx - 1 + MENU_ITEM_COUNT) % MENU_ITEM_COUNT; } break; case APP_STATE_LIFE_COUNTER: if (s_life_mode == LIFE_MODE_TOTAL) { app_set_life(app_get_life() + dir); if (app_get_life() < 0) app_set_life(0); } else if (s_life_mode == LIFE_MODE_POISON) { s_poison += dir; if (s_poison < 0) s_poison = 0; if (s_poison > 10) s_poison = 10; } else if (s_life_mode == LIFE_MODE_CMD_DMG) { s_cmd_dmg += dir; if (s_cmd_dmg < 0) s_cmd_dmg = 0; if (s_cmd_dmg > 21) s_cmd_dmg = 21; } break; case APP_STATE_DICE_ROLLER: if (dir > 0) { s_dice_mode = (s_dice_mode + 1) % 3; } else { s_dice_mode = (s_dice_mode - 1 + 3) % 3; } s_dice_rolled = false; break; case APP_STATE_COMMANDER_TRACKER: if (!s_tax_edit_turns) { s_cmd_casts += dir; if (s_cmd_casts < 0) s_cmd_casts = 0; if (s_cmd_casts > 50) s_cmd_casts = 50; } else { s_turn_count += dir; if (s_turn_count < 1) s_turn_count = 1; if (s_turn_count > 99) s_turn_count = 99; } break; case APP_STATE_TOKEN_SPAWNER: if (!s_token_edit_count) { s_token_idx = (s_token_idx + dir + TOKEN_COUNT) % TOKEN_COUNT; } else { s_tokens[s_token_idx].count += dir; if (s_tokens[s_token_idx].count < 0) s_tokens[s_token_idx].count = 0; if (s_tokens[s_token_idx].count > 99) s_tokens[s_token_idx].count = 99; } break; case APP_STATE_STORAGE_MANAGER: s_storage_option_idx = (s_storage_option_idx == 0) ? 1 : 0; break; default: break; } } /* Redraw view once per rotation event */ switch (cur) { case APP_STATE_MENU: buzzer_chirp(523, 15); view_menu(); break; case APP_STATE_LIFE_COUNTER: buzzer_chirp(700, 15); view_life(); break; case APP_STATE_DICE_ROLLER: buzzer_chirp(600, 15); view_dice(); break; case APP_STATE_COMMANDER_TRACKER: buzzer_chirp(650, 15); view_commander_tax(); break; case APP_STATE_TOKEN_SPAWNER: buzzer_chirp(s_token_edit_count ? 750 : 550, 15); view_token_spawner(); break; case APP_STATE_STORAGE_MANAGER: buzzer_chirp(523, 15); view_storage(); break; case APP_STATE_OTA_UPDATE: { ota_status_t st = ota_manager_get_status(); if (st == OTA_STATUS_UPDATE_AVAILABLE) { ota_manager_reset(); app_set_view(APP_STATE_MENU); view_menu(); } break; } default: break; } } /* ------------------------------------------------------------------ */ /* Task loop */ /* ------------------------------------------------------------------ */ static void ui_task(void *arg) { (void)arg; ESP_LOGI(TAG, "UI task on core %d", xPortGetCoreID()); encoder_init(); buzzer_init(); /* Start on Main Menu at boot */ app_set_view(APP_STATE_MENU); view_menu(); #if CONFIG_MTG_OTA_AUTO_CHECK static bool s_boot_ota_checked = false; static uint32_t s_wifi_up_ms = 0; #endif while (1) { /* 1. New RFID card - scans immediately anywhere */ if (rfid_manager_has_pending_card()) { handle_new_card(rfid_manager_last_uid()); } /* 2. Async Scryfall cache result */ if (s_waiting_fetch && scryfall_has_pending_result()) { handle_fetch_ready(); } /* 3. Button press & hold detection */ int sw_level = gpio_get_level(HW_ENC_SW_GPIO); if (sw_level == 0) { s_btn_held_ms += 25; if (s_btn_held_ms >= 750 && !s_btn_long_fired) { s_btn_long_fired = true; /* Long press: RETURN TO MAIN MENU from any screen */ buzzer_chirp(1046, 50); vTaskDelay(pdMS_TO_TICKS(40)); buzzer_chirp(1318, 70); if (app_get_view() == APP_STATE_REGISTRATION) { wifi_manager_set_power_save(true); } app_set_view(APP_STATE_MENU); view_menu(); } } else { if (s_btn_held_ms >= 35 && !s_btn_long_fired) { handle_encoder_click(); } s_btn_held_ms = 0; s_btn_long_fired = false; } /* 4. Rotary encoder rotation */ if (encoder_has_delta()) { int32_t delta = encoder_get_delta_and_reset(); handle_encoder_rotation(delta); } /* 5. OTA screen live refresh / status change */ if (app_get_view() == APP_STATE_OTA_UPDATE) { ota_status_t cur_ota_st = ota_manager_get_status(); int cur_percent = ota_manager_get_progress(); if (cur_ota_st != s_last_ota_rendered_st) { view_ota(); } else if (cur_ota_st == OTA_STATUS_DOWNLOADING && cur_percent != s_last_ota_percent) { s_last_ota_percent = cur_percent; size_t rb = 0, tb = 0; ota_manager_get_bytes(&rb, &tb); update_ota_progress(cur_percent, rb, tb); } } #if CONFIG_MTG_OTA_AUTO_CHECK /* 6. Automatic background OTA check on startup after Wi-Fi connects */ if (!s_boot_ota_checked && wifi_manager_connected()) { s_wifi_up_ms += 25; if (s_wifi_up_ms >= 4000) { s_boot_ota_checked = true; ESP_LOGI(TAG, "Triggering startup Gitea OTA check..."); ota_manager_check_update_async(); } } if (s_boot_ota_checked && ota_manager_get_status() == OTA_STATUS_UPDATE_AVAILABLE && app_get_view() == APP_STATE_MENU) { app_set_view(APP_STATE_OTA_UPDATE); buzzer_chirp(1046, 120); view_ota(); } #endif vTaskDelay(pdMS_TO_TICKS(25)); } } esp_err_t ui_task_start(void) { xTaskCreatePinnedToCore(ui_task, "ui_task", UI_TASK_STACK, NULL, UI_TASK_PRIO, &s_ui_task, UI_CORE); return ESP_OK; }