diff --git a/main/app_state.h b/main/app_state.h index d4a9da2..66758ea 100644 --- a/main/app_state.h +++ b/main/app_state.h @@ -17,6 +17,7 @@ extern "C" { typedef enum { APP_STATE_BOOT = 0, APP_STATE_MENU, + APP_STATE_SETTINGS_MENU, APP_STATE_SCANNER, APP_STATE_CARD_VIEW, APP_STATE_LIFE_COUNTER, diff --git a/main/buzzer.c b/main/buzzer.c index 56674ab..e82109f 100644 --- a/main/buzzer.c +++ b/main/buzzer.c @@ -7,6 +7,8 @@ #include "esp_log.h" #include "driver/ledc.h" #include "hw_pins.h" +#include "nvs_flash.h" +#include "nvs.h" #include "buzzer.h" #include "freertos/queue.h" @@ -19,6 +21,7 @@ static const char *TAG = "buzzer"; static QueueHandle_t s_sound_queue = NULL; static volatile bool s_melody_playing = false; +static audio_profile_t s_profile = AUDIO_PROFILE_CHIPTUNE; static const buzzer_note_t MELODY_VICTORY[] = { {NOTE_C5, 90}, {NOTE_C5, 90}, {NOTE_C5, 90}, {NOTE_C5, 220}, @@ -79,6 +82,16 @@ static void buzzer_task(void *arg) esp_err_t buzzer_init(void) { + /* Load saved profile from NVS */ + nvs_handle_t h; + if (nvs_open("mtg_cfg", NVS_READONLY, &h) == ESP_OK) { + uint8_t prof = 0; + if (nvs_get_u8(h, "audio_prof", &prof) == ESP_OK && prof <= AUDIO_PROFILE_SILENT) { + s_profile = (audio_profile_t)prof; + } + nvs_close(h); + } + ledc_timer_config_t timer_cfg = { .speed_mode = BUZZER_SPEED_MODE, .timer_num = BUZZER_TIMER_NUM, @@ -109,12 +122,15 @@ esp_err_t buzzer_init(void) xTaskCreate(buzzer_task, "buzzer_task", 2048, NULL, 3, NULL); } - ESP_LOGI(TAG, "buzzer ready on GPIO %d (chiptune synth active)", HW_BUZZER_GPIO); + ESP_LOGI(TAG, "buzzer ready on GPIO %d (profile: %s)", HW_BUZZER_GPIO, buzzer_get_profile_name(s_profile)); return ESP_OK; } esp_err_t buzzer_start(uint32_t freq_hz, uint32_t duty_percent) { + if (s_profile == AUDIO_PROFILE_SILENT) { + return ESP_OK; + } if (freq_hz == 0) { return buzzer_stop(); } @@ -142,10 +158,17 @@ esp_err_t buzzer_stop(void) esp_err_t buzzer_chirp(uint32_t freq_hz, uint32_t duration_ms) { + if (s_profile == AUDIO_PROFILE_SILENT) { + return ESP_OK; + } if (s_melody_playing) { return ESP_OK; /* Don't disrupt ongoing background chiptune melody */ } - esp_err_t ret = buzzer_start(freq_hz, 30); + uint32_t duty = (s_profile == AUDIO_PROFILE_TACTILE) ? 10 : 30; + if (s_profile == AUDIO_PROFILE_TACTILE && duration_ms > 20) { + duration_ms = 20; + } + esp_err_t ret = buzzer_start(freq_hz, duty); if (ret != ESP_OK) { return ret; } @@ -155,8 +178,95 @@ esp_err_t buzzer_chirp(uint32_t freq_hz, uint32_t duration_ms) esp_err_t buzzer_play_sound(buzzer_sound_t sound) { + if (s_profile != AUDIO_PROFILE_CHIPTUNE) { + return ESP_OK; + } if (s_sound_queue != NULL) { xQueueSend(s_sound_queue, &sound, 0); } return ESP_OK; +} + +void buzzer_set_profile(audio_profile_t profile) +{ + s_profile = profile; + nvs_handle_t h; + if (nvs_open("mtg_cfg", NVS_READWRITE, &h) == ESP_OK) { + nvs_set_u8(h, "audio_prof", (uint8_t)profile); + nvs_commit(h); + nvs_close(h); + } + ESP_LOGI(TAG, "Audio profile set to: %s", buzzer_get_profile_name(profile)); +} + +audio_profile_t buzzer_get_profile(void) +{ + return s_profile; +} + +const char *buzzer_get_profile_name(audio_profile_t profile) +{ + switch (profile) { + case AUDIO_PROFILE_CHIPTUNE: return "CHIPTUNE"; + case AUDIO_PROFILE_TACTILE: return "TACTILE"; + case AUDIO_PROFILE_SILENT: return "SILENT"; + default: return "UNKNOWN"; + } +} + +void buzzer_sound_life_gain(void) +{ + if (s_profile == AUDIO_PROFILE_SILENT) return; + if (s_profile == AUDIO_PROFILE_TACTILE) { + buzzer_chirp(NOTE_C6, 10); + return; + } + /* Chiptune: ascending two-tone */ + buzzer_start(NOTE_G5, 25); + vTaskDelay(pdMS_TO_TICKS(22)); + buzzer_start(NOTE_C6, 25); + vTaskDelay(pdMS_TO_TICKS(35)); + buzzer_stop(); +} + +void buzzer_sound_life_loss(void) +{ + if (s_profile == AUDIO_PROFILE_SILENT) return; + if (s_profile == AUDIO_PROFILE_TACTILE) { + buzzer_chirp(NOTE_C4, 12); + return; + } + /* Chiptune: descending two-tone */ + buzzer_start(NOTE_E4, 25); + vTaskDelay(pdMS_TO_TICKS(25)); + buzzer_start(NOTE_C4, 25); + vTaskDelay(pdMS_TO_TICKS(40)); + buzzer_stop(); +} + +void buzzer_sound_rfid_tap(void) +{ + if (s_profile == AUDIO_PROFILE_SILENT) return; + if (s_profile == AUDIO_PROFILE_TACTILE) { + buzzer_chirp(NOTE_A5, 20); + return; + } + /* Chiptune: bright recognition chime */ + buzzer_start(NOTE_A5, 25); + vTaskDelay(pdMS_TO_TICKS(28)); + buzzer_start(NOTE_E6, 25); + vTaskDelay(pdMS_TO_TICKS(45)); + buzzer_stop(); +} + +void buzzer_sound_tick(void) +{ + if (s_profile == AUDIO_PROFILE_SILENT) return; + if (s_profile == AUDIO_PROFILE_TACTILE) { + buzzer_start(NOTE_D6, 8); + vTaskDelay(pdMS_TO_TICKS(6)); + buzzer_stop(); + return; + } + buzzer_chirp(NOTE_C5, 12); } \ No newline at end of file diff --git a/main/buzzer.h b/main/buzzer.h index 721ada5..3997f1c 100644 --- a/main/buzzer.h +++ b/main/buzzer.h @@ -90,7 +90,46 @@ esp_err_t buzzer_play_sound(buzzer_sound_t sound); /** * @brief Play a custom sequence of notes synchronously. */ -esp_err_t buzzer_play_notes(const buzzer_note_t *notes, size_t count); +typedef enum { + AUDIO_PROFILE_CHIPTUNE = 0, /* Full 8-bit melodies & arcade fanfares */ + AUDIO_PROFILE_TACTILE = 1, /* Soft micro-clicks & subtle clean chimes */ + AUDIO_PROFILE_SILENT = 2, /* Mute all sound */ +} audio_profile_t; + +/** + * @brief Set the active audio profile and persist to NVS. + */ +void buzzer_set_profile(audio_profile_t profile); + +/** + * @brief Get the active audio profile. + */ +audio_profile_t buzzer_get_profile(void); + +/** + * @brief Get human-readable string for audio profile. + */ +const char *buzzer_get_profile_name(audio_profile_t profile); + +/** + * @brief Pitch-up feedback when gaining life. + */ +void buzzer_sound_life_gain(void); + +/** + * @brief Pitch-down feedback when losing life. + */ +void buzzer_sound_life_loss(void); + +/** + * @brief Clean notification chime when RFID card is tapped. + */ +void buzzer_sound_rfid_tap(void); + +/** + * @brief Subtle encoder rotary detent click. + */ +void buzzer_sound_tick(void); #ifdef __cplusplus } diff --git a/main/display_manager.c b/main/display_manager.c index 70577df..7066ce0 100644 --- a/main/display_manager.c +++ b/main/display_manager.c @@ -114,4 +114,24 @@ esp_err_t display_manager_init(void) lv_disp_t *display_get_disp(void) { return s_disp; +} + +static bool s_sleeping = false; + +esp_err_t display_set_sleep(bool sleep) +{ + if (s_panel == NULL) { + return ESP_ERR_INVALID_STATE; + } + if (s_sleeping == sleep) { + return ESP_OK; + } + s_sleeping = sleep; + ESP_LOGI(TAG, "Display %s", sleep ? "sleep (off)" : "wake (on)"); + return esp_lcd_panel_disp_on_off(s_panel, !sleep); +} + +bool display_is_sleeping(void) +{ + return s_sleeping; } \ No newline at end of file diff --git a/main/display_manager.h b/main/display_manager.h index 435c40c..70c0099 100644 --- a/main/display_manager.h +++ b/main/display_manager.h @@ -16,6 +16,8 @@ extern "C" { esp_err_t display_manager_init(void); lv_disp_t *display_get_disp(void); +esp_err_t display_set_sleep(bool sleep); +bool display_is_sleeping(void); #ifdef __cplusplus } diff --git a/main/ui_task.c b/main/ui_task.c index c8fd5c5..e9be628 100644 --- a/main/ui_task.c +++ b/main/ui_task.c @@ -39,18 +39,30 @@ static bool s_waiting_fetch = false; static lv_img_dsc_t s_art_img_dsc; static uint16_t *s_art_pixels = NULL; +/* Inactivity sleep timer (3 minutes) */ +#define UI_SLEEP_TIMEOUT_MS (180000) +static uint32_t s_last_activity_ms = 0; + /* Menu items */ -#define MENU_ITEM_COUNT 8 +#define MAIN_MENU_ITEM_COUNT 6 static int s_menu_idx = 0; -static const char *s_menu_labels[MENU_ITEM_COUNT] = { +static const char *s_menu_labels[MAIN_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. UPDATE FIRMWARE" + "6. SETTINGS & TOOLS" +}; + +#define SETTINGS_MENU_ITEM_COUNT 5 +static int s_settings_idx = 0; +static const char *s_settings_labels[SETTINGS_MENU_ITEM_COUNT] = { + "< BACK TO MAIN MENU", + "1. AUDIO: CHIPTUNE", + "2. DECK STORAGE", + "3. DEVICE & WIFI INFO", + "4. UPDATE FIRMWARE" }; /* Life & Counters */ @@ -124,6 +136,7 @@ static lv_obj_t *s_card_scroll_cont = NULL; /* Forward declarations */ static void view_menu(void); +static void view_settings_menu(void); static void view_scanner(void); static void view_card(void); static void view_life(void); @@ -311,13 +324,15 @@ static void view_menu(void) ui_clean_screen(); ui_draw_header("MTG COMPANION",""); - /* 8 Menu Items */ + /* 6 Menu Items */ int start_y = 44; - for (int i = 0; i < MENU_ITEM_COUNT; i++) { + int row_height = 34; + int row_gap = 7; + for (int i = 0; i < MAIN_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_size(item_box, DISP_W - 24, row_height); + lv_obj_align(item_box, LV_ALIGN_TOP_MID, 0, start_y + i * (row_height + row_gap)); 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); @@ -332,12 +347,13 @@ static void view_menu(void) lv_obj_set_style_border_width(item_box, 1, 0); } + const char *item_txt = s_menu_labels[i]; lv_obj_t *lbl = lv_label_create(item_box); if (selected) { - lv_label_set_text_fmt(lbl, "> %s <", s_menu_labels[i]); + lv_label_set_text_fmt(lbl, "> %s <", item_txt); lv_obj_set_style_text_color(lbl, lv_color_black(), 0); } else { - lv_label_set_text_fmt(lbl, " %s", s_menu_labels[i]); + lv_label_set_text_fmt(lbl, " %s", item_txt); lv_obj_set_style_text_color(lbl, lv_color_white(), 0); } lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); @@ -349,7 +365,75 @@ static void view_menu(void) 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); + lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -8); + + lvgl_port_unlock(); +} + +/* Settings & Tools Submenu */ +static void view_settings_menu(void) +{ + encoder_set_steps_per_detent(4); + lvgl_port_lock(0); + ui_clean_screen(); + ui_draw_header("SETTINGS & TOOLS", ""); + + char audio_lbl_buf[36]; + snprintf(audio_lbl_buf, sizeof(audio_lbl_buf), "1. AUDIO: %s", buzzer_get_profile_name(buzzer_get_profile())); + + int start_y = 50; + int row_height = 34; + int row_gap = 8; + + for (int i = 0; i < SETTINGS_MENU_ITEM_COUNT; i++) { + bool selected = (i == s_settings_idx); + lv_obj_t *item_box = lv_obj_create(lv_scr_act()); + lv_obj_set_size(item_box, DISP_W - 24, row_height); + lv_obj_align(item_box, LV_ALIGN_TOP_MID, 0, start_y + i * (row_height + row_gap)); + 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); + } + + const char *item_txt = (i == 1) ? audio_lbl_buf : s_settings_labels[i]; + lv_obj_t *lbl = lv_label_create(item_box); + if (i == 0) { + /* Back button item */ + if (selected) { + lv_label_set_text(lbl, "> < BACK TO MAIN MENU <"); + lv_obj_set_style_text_color(lbl, lv_color_black(), 0); + } else { + lv_label_set_text(lbl, "< BACK TO MAIN MENU"); + lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_AMBER), 0); + } + } else { + if (selected) { + lv_label_set_text_fmt(lbl, "> %s <", item_txt); + lv_obj_set_style_text_color(lbl, lv_color_black(), 0); + } else { + lv_label_set_text_fmt(lbl, " %s", item_txt); + 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, -8); lvgl_port_unlock(); } @@ -625,11 +709,13 @@ static void roll_current_dice(void) if (s_dice_result == 20) { /* Natural 20 - Victory Fanfare! */ buzzer_play_sound(BUZZER_SOUND_VICTORY); + buzzer_chirp(1046, 20); } else if (s_dice_result == 1) { - /* Critical Fail */ - buzzer_chirp(220, 150); + /* Critical Fail - Sad slide fanfare */ + buzzer_play_sound(BUZZER_SOUND_LOSS); + buzzer_chirp(220, 30); } else { - buzzer_chirp(880, 50); + buzzer_chirp(880, 25); } } else if (s_dice_mode == DICE_MODE_D6) { s_dice_result = (int)(esp_random() % 6) + 1; @@ -993,7 +1079,7 @@ static void view_storage(void) 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_label_set_text(b1_lbl, s_storage_option_idx == 1 ? "> BACK <" : "BACK"); 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); @@ -1094,7 +1180,7 @@ static void view_device_info(void) } lv_obj_t *hint = lv_label_create(lv_scr_act()); - lv_label_set_text(hint, "CLICK: MENU"); + lv_label_set_text(hint, "CLICK: BACK • 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, -8); @@ -1252,7 +1338,7 @@ static void view_ota(void) 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_label_set_text(hint, "CLICK: BACK • 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, -8); @@ -1355,7 +1441,7 @@ static void view_ota(void) 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_label_set_text(hint, "CLICK: BACK • 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, -8); @@ -1366,6 +1452,102 @@ static void view_ota(void) lvgl_port_unlock(); } +static void get_mana_badge_colors(const char *sym, uint32_t *bg, uint32_t *text, uint32_t *border) +{ + if (strcasecmp(sym, "W") == 0) { + *bg = 0xF9F6DE; *text = 0x222222; *border = 0xD8D19F; /* MTG White / Ivory */ + } else if (strcasecmp(sym, "U") == 0) { + *bg = 0x0E68AB; *text = 0xFFFFFF; *border = 0x074270; /* MTG Blue */ + } else if (strcasecmp(sym, "B") == 0) { + *bg = 0x242022; *text = 0xEEEEEE; *border = 0x5C5458; /* MTG Black / Charcoal */ + } else if (strcasecmp(sym, "R") == 0) { + *bg = 0xD3202A; *text = 0xFFFFFF; *border = 0x8C151B; /* MTG Red */ + } else if (strcasecmp(sym, "G") == 0) { + *bg = 0x00733E; *text = 0xFFFFFF; *border = 0x004A28; /* MTG Green */ + } else if (strcasecmp(sym, "C") == 0) { + *bg = 0x9E9E9E; *text = 0x111111; *border = 0x616161; /* Colorless Diamond */ + } else if (strchr(sym, 'P') != NULL || strchr(sym, 'p') != NULL) { + *bg = 0x8B0000; *text = 0xFFFFFF; *border = 0x550000; /* Phyrexian Crimson */ + } else if (strchr(sym, '/') != NULL) { + *bg = 0xD4AF37; *text = 0x111111; *border = 0xAA8820; /* Hybrid Gold */ + } else { + *bg = 0xB0BEC5; *text = 0x111111; *border = 0x78909C; /* Mana Silver (0-9, X) */ + } +} + +static int render_mana_symbols(lv_obj_t *parent, const char *mana) +{ + if (mana == NULL || mana[0] == '\0') { + return 0; + } + + int sym_count = 0; + const char *p = mana; + while (*p) { + if (*p == '{') sym_count++; + p++; + } + if (sym_count == 0) { + return 0; + } + + int badge_sz = (sym_count >= 6) ? 15 : 18; + int gap = (sym_count >= 6) ? 2 : 3; + + lv_obj_t *cont = lv_obj_create(parent); + lv_obj_remove_style_all(cont); + lv_obj_set_size(cont, LV_SIZE_CONTENT, badge_sz); + lv_obj_set_layout(cont, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(cont, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(cont, gap, 0); + lv_obj_align(cont, LV_ALIGN_TOP_RIGHT, 0, 0); + lv_obj_clear_flag(cont, LV_OBJ_FLAG_SCROLLABLE); + + p = mana; + while (*p) { + if (*p == '{') { + p++; + const char *end = strchr(p, '}'); + if (!end) break; + size_t token_len = end - p; + if (token_len > 0 && token_len < 8) { + char sym[8]; + memcpy(sym, p, token_len); + sym[token_len] = '\0'; + + uint32_t bg, txt, border; + get_mana_badge_colors(sym, &bg, &txt, &border); + + lv_obj_t *badge = lv_obj_create(cont); + lv_obj_set_size(badge, badge_sz, badge_sz); + lv_obj_set_style_radius(badge, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(badge, lv_color_hex(bg), 0); + lv_obj_set_style_bg_opa(badge, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(badge, lv_color_hex(border), 0); + lv_obj_set_style_border_width(badge, 1, 0); + lv_obj_set_style_pad_all(badge, 0, 0); + lv_obj_clear_flag(badge, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *lbl = lv_label_create(badge); + lv_label_set_text(lbl, sym); + lv_obj_set_style_text_color(lbl, lv_color_hex(txt), 0); +#if LV_FONT_MONTSERRAT_12 + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); +#else + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); +#endif + lv_obj_align(lbl, LV_ALIGN_CENTER, 0, 0); + } + p = end + 1; + } else { + p++; + } + } + + return sym_count * badge_sz + (sym_count - 1) * gap; +} + /* 8. Card View Screen */ static void view_card(void) { @@ -1449,25 +1631,23 @@ static void view_card(void) lv_obj_clear_flag(card_box, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_scrollbar_mode(card_box, LV_SCROLLBAR_MODE_OFF); + /* Pinned Header: Mana Badges */ + int mana_w = 0; + if (mana[0]) { + mana_w = render_mana_symbols(card_box, mana); + } + /* Pinned Header: 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); + int name_max_w = (mana_w > 0) ? (DISP_W - 36 - mana_w - 6) : (DISP_W - 36); + if (name_max_w < 60) name_max_w = 60; 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); - /* Pinned Header: 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); - } - /* Pinned Footer: P/T or Loyalty */ if (power[0] || tough[0]) { lv_obj_t *pt_lbl = lv_label_create(card_box); @@ -1656,7 +1836,7 @@ static void handle_new_card(const char *uid) snprintf(s_card_name, sizeof(s_card_name), "%s", name); app_set_view(APP_STATE_CARD_VIEW); - buzzer_chirp(880, 60); + buzzer_sound_rfid_tap(); char json_path[72]; snprintf(json_path, sizeof(json_path), STORAGE_CARDS_DIR "/%s.json", uid); @@ -1773,16 +1953,44 @@ static void handle_encoder_click(void) view_token_spawner(); break; case 5: + app_set_view(APP_STATE_SETTINGS_MENU); + s_settings_idx = 0; + view_settings_menu(); + break; + default: + break; + } + break; + + case APP_STATE_SETTINGS_MENU: + buzzer_chirp(988, 40); + switch (s_settings_idx) { + case 0: /* < BACK TO MAIN MENU */ + app_set_view(APP_STATE_MENU); + view_menu(); + break; + case 1: { /* Audio profile inline toggle */ + audio_profile_t next_prof = (buzzer_get_profile() + 1) % 3; + buzzer_set_profile(next_prof); + if (next_prof == AUDIO_PROFILE_CHIPTUNE) { + buzzer_chirp(1046, 50); + } else if (next_prof == AUDIO_PROFILE_TACTILE) { + buzzer_chirp(880, 15); + } + view_settings_menu(); + break; + } + case 2: /* DECK STORAGE */ app_set_view(APP_STATE_STORAGE_MANAGER); s_storage_confirm_dialog = false; s_storage_option_idx = 0; view_storage(); break; - case 6: + case 3: /* DEVICE & WIFI INFO */ app_set_view(APP_STATE_DEVICE_INFO); view_device_info(); break; - case 7: + case 4: /* UPDATE FIRMWARE */ app_set_view(APP_STATE_OTA_UPDATE); buzzer_chirp(988, 40); ota_manager_check_update_async(); @@ -1801,8 +2009,8 @@ static void handle_encoder_click(void) 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(); + app_set_view(APP_STATE_SETTINGS_MENU); + view_settings_menu(); } break; } @@ -1836,8 +2044,8 @@ static void handle_encoder_click(void) buzzer_chirp(587, 60); view_storage(); } else { - app_set_view(APP_STATE_MENU); - view_menu(); + app_set_view(APP_STATE_SETTINGS_MENU); + view_settings_menu(); } } else { if (s_storage_option_idx == 0) { @@ -1859,8 +2067,8 @@ static void handle_encoder_click(void) case APP_STATE_DEVICE_INFO: encoder_set_steps_per_detent(4); buzzer_chirp(880, 40); - app_set_view(APP_STATE_MENU); - view_menu(); + app_set_view(APP_STATE_SETTINGS_MENU); + view_settings_menu(); break; case APP_STATE_SCANNER: @@ -1900,7 +2108,7 @@ static void handle_encoder_rotation(int32_t delta) lv_obj_scroll_by_bounded(s_card_scroll_cont, 0, -delta * 35, LV_ANIM_ON); lvgl_port_unlock(); } - buzzer_chirp(600, 10); + buzzer_sound_tick(); return; } @@ -1912,9 +2120,17 @@ static void handle_encoder_rotation(int32_t delta) switch (cur) { case APP_STATE_MENU: if (dir > 0) { - s_menu_idx = (s_menu_idx + 1) % MENU_ITEM_COUNT; + s_menu_idx = (s_menu_idx + 1) % MAIN_MENU_ITEM_COUNT; } else { - s_menu_idx = (s_menu_idx - 1 + MENU_ITEM_COUNT) % MENU_ITEM_COUNT; + s_menu_idx = (s_menu_idx - 1 + MAIN_MENU_ITEM_COUNT) % MAIN_MENU_ITEM_COUNT; + } + break; + + case APP_STATE_SETTINGS_MENU: + if (dir > 0) { + s_settings_idx = (s_settings_idx + 1) % SETTINGS_MENU_ITEM_COUNT; + } else { + s_settings_idx = (s_settings_idx - 1 + SETTINGS_MENU_ITEM_COUNT) % SETTINGS_MENU_ITEM_COUNT; } break; @@ -1976,32 +2192,41 @@ static void handle_encoder_rotation(int32_t delta) /* Redraw view once per rotation event */ switch (cur) { case APP_STATE_MENU: - buzzer_chirp(523, 15); + buzzer_sound_tick(); view_menu(); break; + case APP_STATE_SETTINGS_MENU: + buzzer_sound_tick(); + view_settings_menu(); + break; + case APP_STATE_LIFE_COUNTER: - buzzer_chirp(700, 15); + if (dir > 0) { + buzzer_sound_life_gain(); + } else { + buzzer_sound_life_loss(); + } view_life(); break; case APP_STATE_DICE_ROLLER: - buzzer_chirp(600, 15); + buzzer_sound_tick(); view_dice(); break; case APP_STATE_COMMANDER_TRACKER: - buzzer_chirp(650, 15); + buzzer_sound_tick(); view_commander_tax(); break; case APP_STATE_TOKEN_SPAWNER: - buzzer_chirp(s_token_edit_count ? 750 : 550, 15); + buzzer_sound_tick(); view_token_spawner(); break; case APP_STATE_STORAGE_MANAGER: - buzzer_chirp(523, 15); + buzzer_sound_tick(); view_storage(); break; @@ -2009,8 +2234,8 @@ static void handle_encoder_rotation(int32_t delta) 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(); + app_set_view(APP_STATE_SETTINGS_MENU); + view_settings_menu(); } break; } @@ -2033,6 +2258,7 @@ static void ui_task(void *arg) buzzer_init(); /* Start on Main Menu at boot */ + s_last_activity_ms = pdTICKS_TO_MS(xTaskGetTickCount()); app_set_view(APP_STATE_MENU); view_menu(); @@ -2042,34 +2268,49 @@ static void ui_task(void *arg) #endif while (1) { - /* 1. New RFID card - scans immediately anywhere */ + /* 1. New RFID card - scans immediately anywhere (wakes if sleeping) */ if (rfid_manager_has_pending_card()) { + if (display_is_sleeping()) { + display_set_sleep(false); + } + s_last_activity_ms = pdTICKS_TO_MS(xTaskGetTickCount()); handle_new_card(rfid_manager_last_uid()); } /* 2. Async Scryfall cache result */ if (s_waiting_fetch && scryfall_has_pending_result()) { + s_last_activity_ms = pdTICKS_TO_MS(xTaskGetTickCount()); 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); + if (display_is_sleeping()) { + display_set_sleep(false); + s_last_activity_ms = pdTICKS_TO_MS(xTaskGetTickCount()); + buzzer_sound_tick(); + s_btn_held_ms = 0; + s_btn_long_fired = true; /* Suppress click on release when waking */ + } else { + s_last_activity_ms = pdTICKS_TO_MS(xTaskGetTickCount()); + 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(); } - app_set_view(APP_STATE_MENU); - view_menu(); } } else { if (s_btn_held_ms >= 35 && !s_btn_long_fired) { + s_last_activity_ms = pdTICKS_TO_MS(xTaskGetTickCount()); handle_encoder_click(); } s_btn_held_ms = 0; @@ -2079,7 +2320,22 @@ static void ui_task(void *arg) /* 4. Rotary encoder rotation */ if (encoder_has_delta()) { int32_t delta = encoder_get_delta_and_reset(); - handle_encoder_rotation(delta); + if (display_is_sleeping()) { + display_set_sleep(false); + s_last_activity_ms = pdTICKS_TO_MS(xTaskGetTickCount()); + buzzer_sound_tick(); + } else { + s_last_activity_ms = pdTICKS_TO_MS(xTaskGetTickCount()); + handle_encoder_rotation(delta); + } + } + + /* 5. Inactivity display sleep check (3 minutes) */ + uint32_t now_ms = pdTICKS_TO_MS(xTaskGetTickCount()); + if (!display_is_sleeping() && (now_ms - s_last_activity_ms >= UI_SLEEP_TIMEOUT_MS)) { + if (app_get_view() != APP_STATE_OTA_UPDATE || ota_manager_get_status() != OTA_STATUS_DOWNLOADING) { + display_set_sleep(true); + } } /* 5. OTA screen live refresh / status change */ @@ -2107,7 +2363,7 @@ static void ui_task(void *arg) } } if (s_boot_ota_checked && ota_manager_get_status() == OTA_STATUS_UPDATE_AVAILABLE && - app_get_view() == APP_STATE_MENU) { + (app_get_view() == APP_STATE_MENU || app_get_view() == APP_STATE_SETTINGS_MENU)) { app_set_view(APP_STATE_OTA_UPDATE); buzzer_chirp(1046, 120); view_ota(); diff --git a/sdkconfig b/sdkconfig index 5b7c351..91f0b60 100644 --- a/sdkconfig +++ b/sdkconfig @@ -2232,7 +2232,7 @@ CONFIG_LV_ATTRIBUTE_MEM_ALIGN_SIZE=1 # # CONFIG_LV_FONT_MONTSERRAT_8 is not set # CONFIG_LV_FONT_MONTSERRAT_10 is not set -# CONFIG_LV_FONT_MONTSERRAT_12 is not set +CONFIG_LV_FONT_MONTSERRAT_12=y CONFIG_LV_FONT_MONTSERRAT_14=y # CONFIG_LV_FONT_MONTSERRAT_16 is not set # CONFIG_LV_FONT_MONTSERRAT_18 is not set diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 388edea..1e3d2b3 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -17,6 +17,7 @@ CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y # LVGL & Font Configuration CONFIG_LV_COLOR_16_SWAP=y +CONFIG_LV_FONT_MONTSERRAT_12=y CONFIG_LV_FONT_MONTSERRAT_14=y CONFIG_LV_FONT_MONTSERRAT_20=y CONFIG_LV_FONT_MONTSERRAT_48=y