diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 7dfb00d..d7bc0fd 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -173,6 +173,13 @@ static bool parse_json(const char* json, UsageData* out) { const char* pv = doc["pv"] | (const char*)nullptr; uint32_t ac = doc["ac"] | 0u; if (pv != nullptr || ac != 0u) ui_set_theme(pv, ac); + + // v3 Provider screen badge — optional "pnm" (name) + "pi"/"pc" (1-based + // position / count among enabled providers). Present on the ~60s heartbeat. + const char* pnm = doc["pnm"] | (const char*)nullptr; + int pi = doc["pi"] | 0; + int pc = doc["pc"] | 0; + if (pnm != nullptr || pc != 0) ui_set_provider_badge(pnm, pi, pc); return true; } diff --git a/firmware/src/ui.cpp b/firmware/src/ui.cpp index b6b2cee..28d2c5a 100644 --- a/firmware/src/ui.cpp +++ b/firmware/src/ui.cpp @@ -151,6 +151,11 @@ static lv_obj_t* sess_cost_lbl; static lv_obj_t* sess_tokens_lbl; static lv_obj_t* sess_gen_lbl; static lv_obj_t* sess_msgs_lbl; +static lv_obj_t* provider_container; // which usage source the watch shows (v3) +static lv_obj_t* prov_name_lbl; // active provider's brand name (accent-coloured) +static lv_obj_t* prov_pos_lbl; // "1 / 3" position among enabled providers +static lv_obj_t* prov_switch_btn; // tap → {"cmd":"provnext"} (daemon cycles) +static lv_obj_t* prov_hint_lbl; // shown instead of the button when only one is enabled static lv_obj_t* nowplaying_container; // media now-playing (Phase 5) static lv_obj_t* np_icon_lbl; // play / pause / music glyph static lv_obj_t* np_title_lbl; // track title (scrolls if long) @@ -192,6 +197,7 @@ static const AppEntry APPS[] = { { SCREEN_NOWPLAYING, "Now Playing", LV_SYMBOL_PLAY }, { SCREEN_HOMEASSIST, "Home", LV_SYMBOL_HOME }, { SCREEN_DIMMER, "Dimmer", LV_SYMBOL_SETTINGS }, + { SCREEN_PROVIDER, "Provider", LV_SYMBOL_SHUFFLE }, { SCREEN_BLUETOOTH, "Bluetooth", LV_SYMBOL_BLUETOOTH }, }; #define APP_COUNT (sizeof(APPS) / sizeof(APPS[0])) @@ -736,6 +742,78 @@ static void init_session_screen(lv_obj_t* scr) { lv_obj_add_flag(session_container, LV_OBJ_FLAG_HIDDEN); } +// Provider (v3): which usage source the watch is displaying. Shows the active +// provider's brand name in the accent colour and its position among the enabled +// set; the Switch button asks the daemon to advance to the next one (the daemon +// owns the enabled list/order, so the watch just says "next"). Name/position are +// refreshed from ui_set_provider_badge() as payloads arrive. +static void prov_switch_cb(lv_event_t* e) { + (void)e; + ble_send_command("{\"cmd\":\"provnext\"}"); + if (prov_pos_lbl) lv_label_set_text(prov_pos_lbl, "Switching\xE2\x80\xA6"); // … +} + +static void init_provider_screen(lv_obj_t* scr) { + provider_container = lv_obj_create(scr); + lv_obj_set_size(provider_container, L.scr_w, L.scr_h); + lv_obj_set_pos(provider_container, 0, 0); + lv_obj_set_style_bg_opa(provider_container, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(provider_container, 0, 0); + lv_obj_set_style_pad_all(provider_container, 0, 0); + lv_obj_clear_flag(provider_container, LV_OBJ_FLAG_SCROLLABLE); + + make_screen_title(provider_container, "Provider"); + + lv_obj_t* cap = lv_label_create(provider_container); + lv_label_set_text(cap, "Showing on watch"); + lv_obj_set_style_text_font(cap, &font_styrene_16, 0); + lv_obj_set_style_text_color(cap, COL_DIM, 0); + lv_obj_align(cap, LV_ALIGN_TOP_MID, 0, L.content_y + 34); + + prov_name_lbl = lv_label_create(provider_container); + lv_label_set_text(prov_name_lbl, "Claude Code"); + lv_obj_set_style_text_font(prov_name_lbl, &font_tiempos_34, 0); + lv_obj_add_style(prov_name_lbl, &s_accent_style, 0); // brand colour, recolors on switch + lv_obj_align(prov_name_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 70); + + prov_pos_lbl = lv_label_create(provider_container); + lv_label_set_text(prov_pos_lbl, ""); + lv_obj_set_style_text_font(prov_pos_lbl, &font_styrene_20, 0); + lv_obj_set_style_text_color(prov_pos_lbl, COL_DIM, 0); + lv_obj_align(prov_pos_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 122); + + // Switch button — advance to the next enabled provider. + prov_switch_btn = lv_obj_create(provider_container); + lv_obj_set_size(prov_switch_btn, 190, 74); + lv_obj_align(prov_switch_btn, LV_ALIGN_TOP_MID, 0, L.content_y + 178); + lv_obj_set_style_bg_color(prov_switch_btn, COL_PANEL, 0); + lv_obj_set_style_bg_opa(prov_switch_btn, LV_OPA_COVER, 0); + lv_obj_set_style_radius(prov_switch_btn, 16, 0); + lv_obj_set_style_border_width(prov_switch_btn, 0, 0); + lv_obj_set_style_border_width(prov_switch_btn, 3, LV_STATE_PRESSED); // accent ring on press + lv_obj_set_style_border_color(prov_switch_btn, COL_ACCENT, LV_STATE_PRESSED); + lv_obj_clear_flag(prov_switch_btn, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(prov_switch_btn, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(prov_switch_btn, prov_switch_cb, LV_EVENT_CLICKED, NULL); + + // Brand text is Styrene (ASCII-only); LVGL symbol glyphs live in Montserrat, + // so a symbol here would render as tofu. Keep the label plain "Switch". + lv_obj_t* sw_lbl = lv_label_create(prov_switch_btn); + lv_label_set_text(sw_lbl, "Switch"); + lv_obj_set_style_text_font(sw_lbl, &font_styrene_20, 0); + lv_obj_set_style_text_color(sw_lbl, COL_TEXT, 0); + lv_obj_center(sw_lbl); + + prov_hint_lbl = lv_label_create(provider_container); + lv_label_set_text(prov_hint_lbl, "Only provider enabled"); + lv_obj_set_style_text_font(prov_hint_lbl, &font_styrene_16, 0); + lv_obj_set_style_text_color(prov_hint_lbl, COL_DIM, 0); + lv_obj_align(prov_hint_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 200); + lv_obj_add_flag(prov_hint_lbl, LV_OBJ_FLAG_HIDDEN); // shown only when count == 1 + + lv_obj_add_flag(provider_container, LV_OBJ_FLAG_HIDDEN); +} + // Now Playing: the Windows media session relayed by the daemon. A play/pause glyph // and status line reflect playback; the title scrolls if it overflows. Independent // of rate-limit data, so it stays live even when the OAuth token is unavailable. @@ -1313,6 +1391,7 @@ void ui_init(void) { init_soundpad_screen(scr); init_session_screen(scr); init_nowplaying_screen(scr); + init_provider_screen(scr); init_homeassist_screen(scr); init_dimmer_screen(scr); init_battery_screen(scr); @@ -1546,6 +1625,7 @@ void ui_show_screen(screen_t screen) { if (soundpad_container) lv_obj_add_flag(soundpad_container, LV_OBJ_FLAG_HIDDEN); if (session_container) lv_obj_add_flag(session_container, LV_OBJ_FLAG_HIDDEN); if (nowplaying_container) lv_obj_add_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN); + if (provider_container) lv_obj_add_flag(provider_container, LV_OBJ_FLAG_HIDDEN); if (homeassist_container) lv_obj_add_flag(homeassist_container, LV_OBJ_FLAG_HIDDEN); if (dimmer_container) lv_obj_add_flag(dimmer_container, LV_OBJ_FLAG_HIDDEN); if (battery_container) lv_obj_add_flag(battery_container, LV_OBJ_FLAG_HIDDEN); @@ -1562,6 +1642,7 @@ void ui_show_screen(screen_t screen) { case SCREEN_SOUNDPAD: lv_obj_clear_flag(soundpad_container, LV_OBJ_FLAG_HIDDEN); break; case SCREEN_SESSION: lv_obj_clear_flag(session_container, LV_OBJ_FLAG_HIDDEN); break; case SCREEN_NOWPLAYING: lv_obj_clear_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN); break; + case SCREEN_PROVIDER: lv_obj_clear_flag(provider_container, LV_OBJ_FLAG_HIDDEN); break; case SCREEN_BLUETOOTH: bt_refresh(); lv_obj_clear_flag(bt_container, LV_OBJ_FLAG_HIDDEN); break; case SCREEN_HOMEASSIST: lv_obj_clear_flag(homeassist_container, LV_OBJ_FLAG_HIDDEN); @@ -1633,6 +1714,27 @@ void ui_set_theme(const char* pv, uint32_t accent_rgb) { } } +// Refresh the Provider screen's name + "i / c" position, and show the Switch +// button unless exactly one provider is enabled (then a hint replaces it). Safe +// to call on every payload; name==nullptr leaves the current name in place. +void ui_set_provider_badge(const char* name, int index, int count) { + if (!provider_container) return; + if (name && *name && prov_name_lbl) lv_label_set_text(prov_name_lbl, name); + if (prov_pos_lbl) { + if (count > 0 && index > 0) lv_label_set_text_fmt(prov_pos_lbl, "%d / %d", index, count); + else lv_label_set_text(prov_pos_lbl, ""); + } + bool single = (count == 1); // count 0 (unknown) keeps the button visible + if (prov_switch_btn) { + if (single) lv_obj_add_flag(prov_switch_btn, LV_OBJ_FLAG_HIDDEN); + else lv_obj_clear_flag(prov_switch_btn, LV_OBJ_FLAG_HIDDEN); + } + if (prov_hint_lbl) { + if (single) lv_obj_clear_flag(prov_hint_lbl, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(prov_hint_lbl, LV_OBJ_FLAG_HIDDEN); + } +} + void ui_update_ble_status(ble_state_t state, const char* name, const char* mac) { (void)name; (void)mac; bool was_connected = s_ble_connected; diff --git a/firmware/src/ui.h b/firmware/src/ui.h index 98e58de..673e336 100644 --- a/firmware/src/ui.h +++ b/firmware/src/ui.h @@ -12,6 +12,7 @@ enum screen_t { SCREEN_HOMEASSIST, // Home Assistant controls (Phase 6) SCREEN_DIMMER, // tilt-to-dim light control (Phase 6 step 3 / M3) SCREEN_BLUETOOTH, // BLE connection info + SCREEN_PROVIDER, // which usage source the watch displays (v3) + Switch SCREEN_BATTERY, // battery detail (voltage + time left) — opened by tapping the battery icon SCREEN_COUNT, }; @@ -25,6 +26,12 @@ void ui_init(void); // Change-guarded, so it's safe to call on every payload. void ui_set_theme(const char* provider_id, uint32_t accent_rgb); +// v3 Provider screen badge: the active provider's human name (`pnm`) and its +// 1-based position/count among the enabled providers (`pi`/`pc`), so the screen +// shows "Claude Code · 1 / 3" and hides the Switch button when only one exists. +// Safe to call on every payload. name==nullptr keeps the current name. +void ui_set_provider_badge(const char* name, int index, int count); + void ui_update(const UsageData* data); void ui_tick_anim(void); void ui_show_screen(screen_t screen);