Replace the splash<->usage tap-toggle with a home + launcher model. Tapping the Claude logo on the home (usage) screen opens a scrollable 2-column launcher driven by an app registry — adding a screen is one registry line plus a builder, no edits to shared navigation. A shared back chevron returns launcher->home and app->launcher; tapping the splash returns to home. Screens wired: Usage (home) and Animations (splash) are real; Bluetooth shows live connection state + device name + MAC; Soundpad/Session/Now Playing/Home point at a shared "coming soon" stub until their phases land. Enable Montserrat 20/28 for the LVGL symbol glyphs used by tile icons and the back chevron (the brand Styrene/Tiempos fonts are Latin-only). Flash 20.3% -> 20.9%. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
830 lines
33 KiB
C++
830 lines
33 KiB
C++
#include "ui.h"
|
|
#include "splash.h"
|
|
#include <lvgl.h>
|
|
#include "logo.h"
|
|
#include "icons.h"
|
|
#include "hal/board_caps.h"
|
|
|
|
// Custom fonts (scaled for 314 PPI, ~1.9x from original 165 PPI)
|
|
LV_FONT_DECLARE(font_tiempos_56);
|
|
LV_FONT_DECLARE(font_tiempos_34);
|
|
LV_FONT_DECLARE(font_styrene_48);
|
|
LV_FONT_DECLARE(font_styrene_28);
|
|
LV_FONT_DECLARE(font_styrene_24);
|
|
LV_FONT_DECLARE(font_styrene_20);
|
|
LV_FONT_DECLARE(font_styrene_16);
|
|
LV_FONT_DECLARE(font_styrene_14);
|
|
LV_FONT_DECLARE(font_mono_32);
|
|
|
|
// Layout values computed from the active board's geometry. Populated once
|
|
// in ui_init() and treated as const for the rest of the program. Adding a
|
|
// new display size means extending compute_layout() with another
|
|
// breakpoint — never editing the screen-builder functions below.
|
|
struct Layout {
|
|
int16_t scr_w, scr_h;
|
|
int16_t margin;
|
|
int16_t title_y;
|
|
int16_t content_y;
|
|
int16_t content_w;
|
|
|
|
// Usage screen
|
|
int16_t usage_panel_h;
|
|
int16_t usage_panel_gap;
|
|
int16_t usage_bar_y;
|
|
int16_t usage_reset_y;
|
|
|
|
// Bluetooth screen
|
|
int16_t bt_info_panel_h;
|
|
int16_t bt_reset_zone_h;
|
|
const lv_font_t* bt_title_font;
|
|
const lv_font_t* bt_status_font;
|
|
const lv_font_t* bt_device_font;
|
|
const lv_font_t* bt_credit_1_font;
|
|
const lv_font_t* bt_credit_2_font;
|
|
};
|
|
static Layout L = {};
|
|
|
|
// Pick layout values from the active board's pixel dimensions. The two
|
|
// existing boards happen to land on the two breakpoints below; new ports
|
|
// inherit the closer one — visually OK, may need a polish pass for
|
|
// pixel-perfect alignment but never blocks the port from booting.
|
|
static void compute_layout(const BoardCaps& c) {
|
|
L.scr_w = c.width;
|
|
L.scr_h = c.height;
|
|
L.margin = 20;
|
|
L.title_y = 30;
|
|
|
|
if (c.height >= 460) {
|
|
// Large layout — tuned for 480x480 (AMOLED-2.16).
|
|
L.content_y = 100;
|
|
L.usage_panel_h = 150;
|
|
L.usage_panel_gap = 16;
|
|
L.usage_bar_y = 56;
|
|
L.usage_reset_y = 94;
|
|
L.bt_info_panel_h = 160;
|
|
L.bt_reset_zone_h = 110;
|
|
L.bt_title_font = &font_tiempos_56;
|
|
L.bt_status_font = &font_styrene_48;
|
|
L.bt_device_font = &font_styrene_28;
|
|
L.bt_credit_1_font = &font_styrene_24;
|
|
L.bt_credit_2_font = &font_styrene_20;
|
|
} else {
|
|
// Compact layout — tuned for 368x448 (AMOLED-1.8).
|
|
L.content_y = 85;
|
|
L.usage_panel_h = 130;
|
|
L.usage_panel_gap = 12;
|
|
L.usage_bar_y = 48;
|
|
L.usage_reset_y = 78;
|
|
L.bt_info_panel_h = 140;
|
|
L.bt_reset_zone_h = 90;
|
|
L.bt_title_font = &font_tiempos_34;
|
|
L.bt_status_font = &font_styrene_28;
|
|
L.bt_device_font = &font_styrene_20;
|
|
L.bt_credit_1_font = &font_styrene_16;
|
|
L.bt_credit_2_font = &font_styrene_14;
|
|
}
|
|
|
|
L.content_w = L.scr_w - 2 * L.margin;
|
|
}
|
|
|
|
// Anthropic brand palette — design tokens live in theme.h
|
|
#include "theme.h"
|
|
#define COL_BG THEME_BG
|
|
#define COL_PANEL THEME_PANEL
|
|
#define COL_TEXT THEME_TEXT
|
|
#define COL_DIM THEME_DIM
|
|
#define COL_ACCENT THEME_ACCENT
|
|
#define COL_GREEN THEME_GREEN
|
|
#define COL_AMBER THEME_AMBER
|
|
#define COL_RED THEME_RED
|
|
#define COL_BAR_BG THEME_BAR_BG
|
|
|
|
// ---- Usage screen widgets (single non-splash view) ----
|
|
static lv_obj_t* usage_container;
|
|
static lv_obj_t* lbl_title;
|
|
static lv_obj_t* usage_group; // the two usage panels — shown when connected
|
|
static lv_obj_t* pair_group; // pairing hint — shown when disconnected
|
|
static lv_obj_t* bar_session;
|
|
static lv_obj_t* lbl_session_pct;
|
|
static lv_obj_t* lbl_session_label;
|
|
static lv_obj_t* lbl_session_reset;
|
|
static lv_obj_t* bar_weekly;
|
|
static lv_obj_t* lbl_weekly_pct;
|
|
static lv_obj_t* lbl_weekly_label;
|
|
static lv_obj_t* lbl_weekly_reset;
|
|
static lv_obj_t* lbl_anim; // status line: connection state + whimsical idle
|
|
|
|
// ---- Battery indicator (shared, on top) ----
|
|
static lv_obj_t* battery_img;
|
|
static lv_obj_t* logo_img;
|
|
static lv_image_dsc_t battery_dscs[5]; // empty, low, medium, full, charging
|
|
|
|
// ---- v2 navigation: launcher, stub & bluetooth screens ----
|
|
static lv_obj_t* nav_back_btn; // top-left back chevron (shown off the home screen)
|
|
static lv_obj_t* menu_container; // app launcher (scrollable tile grid)
|
|
static lv_obj_t* stub_container; // generic "coming soon" screen, retitled per app
|
|
static lv_obj_t* stub_title;
|
|
static lv_obj_t* stub_icon;
|
|
static lv_obj_t* bt_container; // BLE connection info
|
|
static lv_obj_t* bt_status_lbl;
|
|
static lv_obj_t* bt_name_lbl;
|
|
static lv_obj_t* bt_mac_lbl;
|
|
|
|
// App registry — the launcher renders one tile per entry, so adding a screen is
|
|
// one line here plus its builder. Order = tile order. "Animations" reuses the
|
|
// splash; the four future apps open the shared stub until their phase lands.
|
|
struct AppEntry {
|
|
screen_t screen;
|
|
const char* label;
|
|
const char* symbol; // LVGL/Montserrat symbol glyph
|
|
};
|
|
static const AppEntry APPS[] = {
|
|
{ SCREEN_USAGE, "Usage", LV_SYMBOL_CHARGE },
|
|
{ SCREEN_SPLASH, "Animations", LV_SYMBOL_IMAGE },
|
|
{ SCREEN_SOUNDPAD, "Soundpad", LV_SYMBOL_AUDIO },
|
|
{ SCREEN_SESSION, "Session", LV_SYMBOL_LIST },
|
|
{ SCREEN_NOWPLAYING, "Now Playing", LV_SYMBOL_PLAY },
|
|
{ SCREEN_HOMEASSIST, "Home", LV_SYMBOL_HOME },
|
|
{ SCREEN_BLUETOOTH, "Bluetooth", LV_SYMBOL_BLUETOOTH },
|
|
};
|
|
#define APP_COUNT (sizeof(APPS) / sizeof(APPS[0]))
|
|
|
|
// ---- Live-data freshness → which usage sub-view to show ----
|
|
// usage panels when data is flowing, an idle "Zzz" screen when the host is
|
|
// connected but no usage update landed within DATA_FRESH_MS, the pairing hint
|
|
// when BLE is down. Re-evaluated every loop in ui_tick_anim().
|
|
static lv_obj_t* idle_group; // the "Zzz" idle screen
|
|
static uint32_t last_data_ms = 0; // lv_tick when the last valid usage update landed
|
|
static bool data_received = false; // any valid update since boot
|
|
static int view_state = -1; // -1 unknown / 0 pair / 1 idle / 2 usage
|
|
static const uint32_t DATA_FRESH_MS = 90000; // usage counts as "live" within this window (daemon sends ~60s)
|
|
|
|
// ---- Shared ----
|
|
static lv_image_dsc_t logo_dsc;
|
|
static screen_t current_screen = SCREEN_USAGE;
|
|
static bool s_ble_connected = false; // cached BLE connection state
|
|
static uint32_t connected_at_ms = 0; // when we last entered CONNECTED ("Connected" dwell)
|
|
|
|
// Animation state
|
|
static uint32_t anim_last_ms = 0;
|
|
static uint8_t anim_spinner_idx = 0;
|
|
static uint8_t anim_phase = 0;
|
|
static uint8_t anim_msg_idx = 0;
|
|
static uint32_t anim_msg_start = 0;
|
|
#define ANIM_MSG_MS 4000
|
|
|
|
static const char* const spinner_frames[] = {
|
|
"\xC2\xB7", "\xE2\x9C\xBB", "\xE2\x9C\xBD",
|
|
"\xE2\x9C\xB6", "\xE2\x9C\xB3", "\xE2\x9C\xA2",
|
|
};
|
|
#define SPINNER_COUNT 6
|
|
#define SPINNER_PHASES (2 * (SPINNER_COUNT - 1)) // 10: ping-pong 0..5..0
|
|
|
|
static const uint16_t spinner_ms[SPINNER_COUNT] = {
|
|
260, 130, 130, 130, 130, 260,
|
|
};
|
|
|
|
static const char* const anim_messages[] = {
|
|
"Accomplishing", "Elucidating", "Perusing",
|
|
"Actioning", "Enchanting", "Philosophising",
|
|
"Actualizing", "Envisioning", "Pondering",
|
|
"Baking", "Finagling", "Pontificating",
|
|
"Booping", "Flibbertigibbeting", "Processing",
|
|
"Brewing", "Forging", "Puttering",
|
|
"Calculating", "Forming", "Puzzling",
|
|
"Cerebrating", "Frolicking", "Reticulating",
|
|
"Channelling", "Generating", "Ruminating",
|
|
"Churning", "Germinating", "Scheming",
|
|
"Clauding", "Hatching", "Schlepping",
|
|
"Coalescing", "Herding", "Shimmying",
|
|
"Cogitating", "Honking", "Shucking",
|
|
"Combobulating", "Hustling", "Simmering",
|
|
"Computing", "Ideating", "Smooshing",
|
|
"Concocting", "Imagining", "Spelunking",
|
|
"Conjuring", "Incubating", "Spinning",
|
|
"Considering", "Inferring", "Stewing",
|
|
"Contemplating", "Jiving", "Sussing",
|
|
"Cooking", "Manifesting", "Synthesizing",
|
|
"Crafting", "Marinating", "Thinking",
|
|
"Creating", "Meandering", "Tinkering",
|
|
"Crunching", "Moseying", "Transmuting",
|
|
"Deciphering", "Mulling", "Unfurling",
|
|
"Deliberating", "Mustering", "Unravelling",
|
|
"Determining", "Musing", "Vibing",
|
|
"Discombobulating", "Noodling", "Wandering",
|
|
"Divining", "Percolating", "Whirring",
|
|
"Doing", "Wibbling",
|
|
"Effecting", "Wizarding",
|
|
"Working", "Wrangling",
|
|
};
|
|
#define ANIM_MSG_COUNT (sizeof(anim_messages) / sizeof(anim_messages[0]))
|
|
|
|
static lv_color_t pct_color(float pct) {
|
|
if (pct >= 80.0f) return COL_RED;
|
|
if (pct >= 50.0f) return COL_AMBER;
|
|
return COL_GREEN;
|
|
}
|
|
|
|
static void format_reset_time(int mins, char* buf, size_t len) {
|
|
if (mins < 0) {
|
|
snprintf(buf, len, "---");
|
|
} else if (mins < 60) {
|
|
snprintf(buf, len, "Resets in %dm", mins);
|
|
} else if (mins < 1440) {
|
|
snprintf(buf, len, "Resets in %dh %dm", mins / 60, mins % 60);
|
|
} else {
|
|
snprintf(buf, len, "Resets in %dd %dh", mins / 1440, (mins % 1440) / 60);
|
|
}
|
|
}
|
|
|
|
// Forward decls — callbacks defined near ui_show_screen below
|
|
static void global_click_cb(lv_event_t* e);
|
|
static void logo_click_cb(lv_event_t* e);
|
|
static void nav_back_cb(lv_event_t* e);
|
|
|
|
static lv_obj_t* make_panel(lv_obj_t* parent, int x, int y, int w, int h) {
|
|
lv_obj_t* panel = lv_obj_create(parent);
|
|
lv_obj_set_pos(panel, x, y);
|
|
lv_obj_set_size(panel, w, h);
|
|
lv_obj_set_style_bg_color(panel, COL_PANEL, 0);
|
|
lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0);
|
|
lv_obj_set_style_radius(panel, 8, 0);
|
|
lv_obj_set_style_border_width(panel, 0, 0);
|
|
lv_obj_set_style_pad_left(panel, 16, 0);
|
|
lv_obj_set_style_pad_right(panel, 16, 0);
|
|
lv_obj_set_style_pad_top(panel, 12, 0);
|
|
lv_obj_set_style_pad_bottom(panel, 12, 0);
|
|
lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_add_flag(panel, LV_OBJ_FLAG_EVENT_BUBBLE);
|
|
return panel;
|
|
}
|
|
|
|
static lv_obj_t* make_bar(lv_obj_t* parent, int x, int y, int w, int h) {
|
|
lv_obj_t* bar = lv_bar_create(parent);
|
|
lv_obj_set_pos(bar, x, y);
|
|
lv_obj_set_size(bar, w, h);
|
|
lv_bar_set_range(bar, 0, 100);
|
|
lv_bar_set_value(bar, 0, LV_ANIM_OFF);
|
|
lv_obj_set_style_bg_color(bar, COL_BAR_BG, LV_PART_MAIN);
|
|
lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, LV_PART_MAIN);
|
|
lv_obj_set_style_radius(bar, 6, LV_PART_MAIN);
|
|
lv_obj_set_style_bg_color(bar, COL_GREEN, LV_PART_INDICATOR);
|
|
lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, LV_PART_INDICATOR);
|
|
lv_obj_set_style_radius(bar, 6, LV_PART_INDICATOR);
|
|
return bar;
|
|
}
|
|
|
|
static void init_icon_dsc_rgb565a8(lv_image_dsc_t* dsc, int w, int h, const uint8_t* data) {
|
|
dsc->header.w = w;
|
|
dsc->header.h = h;
|
|
dsc->header.cf = LV_COLOR_FORMAT_RGB565A8;
|
|
dsc->header.stride = w * 2;
|
|
dsc->data = data;
|
|
dsc->data_size = w * h * 3;
|
|
}
|
|
|
|
static lv_obj_t* make_pill(lv_obj_t* parent, const char* text) {
|
|
lv_obj_t* lbl = lv_label_create(parent);
|
|
lv_label_set_text(lbl, text);
|
|
lv_obj_set_style_text_font(lbl, &font_styrene_28, 0);
|
|
lv_obj_set_style_text_color(lbl, COL_TEXT, 0);
|
|
lv_obj_set_style_bg_color(lbl, COL_BAR_BG, 0);
|
|
lv_obj_set_style_bg_opa(lbl, LV_OPA_COVER, 0);
|
|
lv_obj_set_style_radius(lbl, LV_RADIUS_CIRCLE, 0);
|
|
lv_obj_set_style_pad_left(lbl, 18, 0);
|
|
lv_obj_set_style_pad_right(lbl, 18, 0);
|
|
lv_obj_set_style_pad_top(lbl, 6, 0);
|
|
lv_obj_set_style_pad_bottom(lbl, 6, 0);
|
|
return lbl;
|
|
}
|
|
|
|
static void init_battery_icons(void) {
|
|
init_icon_dsc_rgb565a8(&battery_dscs[0], ICON_BATTERY_W, ICON_BATTERY_H, icon_battery_data);
|
|
init_icon_dsc_rgb565a8(&battery_dscs[1], ICON_BATTERY_LOW_W, ICON_BATTERY_LOW_H, icon_battery_low_data);
|
|
init_icon_dsc_rgb565a8(&battery_dscs[2], ICON_BATTERY_MEDIUM_W, ICON_BATTERY_MEDIUM_H, icon_battery_medium_data);
|
|
init_icon_dsc_rgb565a8(&battery_dscs[3], ICON_BATTERY_FULL_W, ICON_BATTERY_FULL_H, icon_battery_full_data);
|
|
init_icon_dsc_rgb565a8(&battery_dscs[4], ICON_BATTERY_CHARGING_W, ICON_BATTERY_CHARGING_H, icon_battery_charging_data);
|
|
}
|
|
|
|
// ======== Usage Screen ========
|
|
|
|
static void make_usage_panel(lv_obj_t* parent, int y, const char* pill_text,
|
|
lv_obj_t** out_pct, lv_obj_t** out_pill,
|
|
lv_obj_t** out_bar, lv_obj_t** out_reset) {
|
|
lv_obj_t* panel = make_panel(parent, L.margin, y, L.content_w, L.usage_panel_h);
|
|
|
|
*out_pct = lv_label_create(panel);
|
|
lv_label_set_text(*out_pct, "---%");
|
|
lv_obj_set_style_text_font(*out_pct, &font_styrene_48, 0);
|
|
lv_obj_set_style_text_color(*out_pct, COL_TEXT, 0);
|
|
lv_obj_set_pos(*out_pct, 0, 0);
|
|
|
|
*out_pill = make_pill(panel, pill_text);
|
|
lv_obj_align(*out_pill, LV_ALIGN_TOP_RIGHT, 0, 1);
|
|
|
|
*out_bar = make_bar(panel, 0, L.usage_bar_y, L.content_w - 32, 24);
|
|
|
|
*out_reset = lv_label_create(panel);
|
|
lv_label_set_text(*out_reset, "---");
|
|
lv_obj_set_style_text_font(*out_reset, &font_styrene_28, 0);
|
|
lv_obj_set_style_text_color(*out_reset, COL_DIM, 0);
|
|
lv_obj_set_pos(*out_reset, 0, L.usage_reset_y);
|
|
}
|
|
|
|
// Pairing hint — shown when disconnected so the screen isn't empty and the
|
|
// user knows how to (re)pair. Wording matches the 3-second release gesture.
|
|
static void build_pair_group(lv_obj_t* parent) {
|
|
pair_group = lv_obj_create(parent);
|
|
lv_obj_set_size(pair_group, L.scr_w, L.scr_h - L.content_y);
|
|
lv_obj_set_pos(pair_group, 0, L.content_y);
|
|
lv_obj_set_style_bg_opa(pair_group, LV_OPA_TRANSP, 0);
|
|
lv_obj_set_style_border_width(pair_group, 0, 0);
|
|
lv_obj_set_style_pad_all(pair_group, 0, 0);
|
|
lv_obj_clear_flag(pair_group, LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_add_flag(pair_group, LV_OBJ_FLAG_EVENT_BUBBLE);
|
|
|
|
lv_obj_t* l1 = lv_label_create(pair_group);
|
|
lv_label_set_text(l1, "To pair");
|
|
lv_obj_set_style_text_font(l1, L.bt_status_font, 0);
|
|
lv_obj_set_style_text_color(l1, COL_TEXT, 0);
|
|
lv_obj_align(l1, LV_ALIGN_TOP_MID, 0, 40);
|
|
|
|
lv_obj_t* l2 = lv_label_create(pair_group);
|
|
lv_label_set_text(l2, "hold the power button");
|
|
lv_obj_set_style_text_font(l2, L.bt_device_font, 0);
|
|
lv_obj_set_style_text_color(l2, COL_DIM, 0);
|
|
lv_obj_align(l2, LV_ALIGN_TOP_MID, 0, 120);
|
|
|
|
lv_obj_t* l3 = lv_label_create(pair_group);
|
|
lv_label_set_text(l3, "for 3 seconds, then release");
|
|
lv_obj_set_style_text_font(l3, L.bt_device_font, 0);
|
|
lv_obj_set_style_text_color(l3, COL_DIM, 0);
|
|
lv_obj_align(l3, LV_ALIGN_TOP_MID, 0, 160);
|
|
|
|
lv_obj_add_flag(pair_group, LV_OBJ_FLAG_HIDDEN); // ui_update_ble_status decides
|
|
}
|
|
|
|
// Idle "Zzz" screen — shown when the host is connected but no usage update has
|
|
// landed recently (token expired, daemon down, host asleep…). Full-screen, like
|
|
// the pairing hint, so we never render hours-old numbers as if they were live.
|
|
static void build_idle_group(lv_obj_t* parent) {
|
|
idle_group = lv_obj_create(parent);
|
|
lv_obj_set_size(idle_group, L.scr_w, L.scr_h - L.content_y);
|
|
lv_obj_set_pos(idle_group, 0, L.content_y);
|
|
lv_obj_set_style_bg_opa(idle_group, LV_OPA_TRANSP, 0);
|
|
lv_obj_set_style_border_width(idle_group, 0, 0);
|
|
lv_obj_set_style_pad_all(idle_group, 0, 0);
|
|
lv_obj_clear_flag(idle_group, LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_add_flag(idle_group, LV_OBJ_FLAG_EVENT_BUBBLE);
|
|
|
|
// A shrunk-down sleeping creature (reused claudepix "expression sleep" art)
|
|
// sits between the header and the status line; the animated "Listening…"
|
|
// status line carries the words, so no extra text is needed here.
|
|
lv_obj_t* creature = splash_mini_create(idle_group, "expression sleep", 160);
|
|
if (creature) lv_obj_align(creature, LV_ALIGN_CENTER, 0, -20);
|
|
|
|
lv_obj_add_flag(idle_group, LV_OBJ_FLAG_HIDDEN); // update_view_state decides
|
|
}
|
|
|
|
static void init_usage_screen(lv_obj_t* scr) {
|
|
usage_container = lv_obj_create(scr);
|
|
lv_obj_set_size(usage_container, L.scr_w, L.scr_h);
|
|
lv_obj_set_pos(usage_container, 0, 0);
|
|
lv_obj_set_style_bg_opa(usage_container, LV_OPA_TRANSP, 0);
|
|
lv_obj_set_style_border_width(usage_container, 0, 0);
|
|
lv_obj_set_style_pad_all(usage_container, 0, 0);
|
|
lv_obj_clear_flag(usage_container, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
lbl_title = lv_label_create(usage_container);
|
|
lv_label_set_text(lbl_title, "Usage");
|
|
lv_obj_set_style_text_font(lbl_title, &font_tiempos_56, 0);
|
|
lv_obj_set_style_text_color(lbl_title, COL_TEXT, 0);
|
|
lv_obj_align(lbl_title, LV_ALIGN_TOP_MID, 16, L.title_y);
|
|
|
|
// Usage panels (shown when connected) live in a transparent full-size group
|
|
// so they can be toggled against the pairing hint as one unit.
|
|
usage_group = lv_obj_create(usage_container);
|
|
lv_obj_set_size(usage_group, L.scr_w, L.scr_h);
|
|
lv_obj_set_pos(usage_group, 0, 0);
|
|
lv_obj_set_style_bg_opa(usage_group, LV_OPA_TRANSP, 0);
|
|
lv_obj_set_style_border_width(usage_group, 0, 0);
|
|
lv_obj_set_style_pad_all(usage_group, 0, 0);
|
|
lv_obj_clear_flag(usage_group, LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_add_flag(usage_group, LV_OBJ_FLAG_EVENT_BUBBLE);
|
|
|
|
make_usage_panel(usage_group, L.content_y, "Current",
|
|
&lbl_session_pct, &lbl_session_label,
|
|
&bar_session, &lbl_session_reset);
|
|
make_usage_panel(usage_group,
|
|
L.content_y + L.usage_panel_h + L.usage_panel_gap, "Weekly",
|
|
&lbl_weekly_pct, &lbl_weekly_label,
|
|
&bar_weekly, &lbl_weekly_reset);
|
|
|
|
build_pair_group(usage_container);
|
|
build_idle_group(usage_container);
|
|
|
|
// Status line — always visible on the usage view. Driven by ui_tick_anim().
|
|
lbl_anim = lv_label_create(usage_container);
|
|
lv_label_set_text(lbl_anim, "");
|
|
lv_obj_set_style_text_font(lbl_anim, &font_mono_32, 0);
|
|
lv_obj_set_style_text_color(lbl_anim, COL_ACCENT, 0);
|
|
lv_obj_align(lbl_anim, LV_ALIGN_BOTTOM_MID, 0, -15);
|
|
}
|
|
|
|
// ======== v2: launcher, stub & bluetooth screens ========
|
|
|
|
static const AppEntry* app_for_screen(screen_t s) {
|
|
for (size_t i = 0; i < APP_COUNT; i++)
|
|
if (APPS[i].screen == s) return &APPS[i];
|
|
return nullptr;
|
|
}
|
|
|
|
// Centered screen title for the navigation screens. The back chevron lives in
|
|
// the shared top-left chrome (nav_back_btn), so only the title is drawn here.
|
|
static lv_obj_t* make_screen_title(lv_obj_t* parent, const char* text) {
|
|
lv_obj_t* t = lv_label_create(parent);
|
|
lv_label_set_text(t, text);
|
|
lv_obj_set_style_text_font(t, &font_styrene_28, 0);
|
|
lv_obj_set_style_text_color(t, COL_TEXT, 0);
|
|
lv_obj_align(t, LV_ALIGN_TOP_MID, 16, L.title_y);
|
|
return t;
|
|
}
|
|
|
|
static void tile_click_cb(lv_event_t* e) {
|
|
screen_t s = (screen_t)(intptr_t)lv_event_get_user_data(e);
|
|
ui_show_screen(s);
|
|
}
|
|
|
|
static lv_obj_t* make_tile(lv_obj_t* parent, const AppEntry* app, int w, int h) {
|
|
lv_obj_t* tile = lv_obj_create(parent);
|
|
lv_obj_set_size(tile, w, h);
|
|
lv_obj_set_style_bg_color(tile, COL_PANEL, 0);
|
|
lv_obj_set_style_bg_opa(tile, LV_OPA_COVER, 0);
|
|
lv_obj_set_style_radius(tile, 16, 0);
|
|
lv_obj_set_style_border_width(tile, 0, 0);
|
|
lv_obj_set_style_pad_all(tile, 6, 0);
|
|
lv_obj_set_flex_flow(tile, LV_FLEX_FLOW_COLUMN);
|
|
lv_obj_set_flex_align(tile, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
|
lv_obj_clear_flag(tile, LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_add_flag(tile, LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_add_event_cb(tile, tile_click_cb, LV_EVENT_CLICKED, (void*)(intptr_t)app->screen);
|
|
|
|
lv_obj_t* icon = lv_label_create(tile);
|
|
lv_label_set_text(icon, app->symbol);
|
|
lv_obj_set_style_text_font(icon, &lv_font_montserrat_28, 0);
|
|
lv_obj_set_style_text_color(icon, COL_ACCENT, 0);
|
|
|
|
lv_obj_t* lbl = lv_label_create(tile);
|
|
lv_label_set_text(lbl, app->label);
|
|
lv_obj_set_style_text_font(lbl, &font_styrene_16, 0);
|
|
lv_obj_set_style_text_color(lbl, COL_TEXT, 0);
|
|
lv_obj_set_style_pad_top(lbl, 6, 0);
|
|
return tile;
|
|
}
|
|
|
|
static void init_menu_screen(lv_obj_t* scr) {
|
|
menu_container = lv_obj_create(scr);
|
|
lv_obj_set_size(menu_container, L.scr_w, L.scr_h);
|
|
lv_obj_set_pos(menu_container, 0, 0);
|
|
lv_obj_set_style_bg_opa(menu_container, LV_OPA_TRANSP, 0);
|
|
lv_obj_set_style_border_width(menu_container, 0, 0);
|
|
lv_obj_set_style_pad_all(menu_container, 0, 0);
|
|
lv_obj_clear_flag(menu_container, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
make_screen_title(menu_container, "Menu");
|
|
|
|
// Scrollable 2-column tile grid — wraps and scrolls vertically as the app
|
|
// registry grows, so new screens need no layout changes here.
|
|
lv_obj_t* grid = lv_obj_create(menu_container);
|
|
lv_obj_set_size(grid, L.content_w, L.scr_h - L.content_y - L.margin);
|
|
lv_obj_set_pos(grid, L.margin, L.content_y);
|
|
lv_obj_set_style_bg_opa(grid, LV_OPA_TRANSP, 0);
|
|
lv_obj_set_style_border_width(grid, 0, 0);
|
|
lv_obj_set_style_pad_all(grid, 0, 0);
|
|
lv_obj_set_style_pad_row(grid, 12, 0);
|
|
lv_obj_set_style_pad_column(grid, 12, 0);
|
|
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
|
|
lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
|
lv_obj_set_scroll_dir(grid, LV_DIR_VER);
|
|
lv_obj_set_scrollbar_mode(grid, LV_SCROLLBAR_MODE_AUTO);
|
|
|
|
int tile_w = (L.content_w - 16) / 2; // 4px slack so two fit per row beside a scrollbar
|
|
int tile_h = 116;
|
|
for (size_t i = 0; i < APP_COUNT; i++)
|
|
make_tile(grid, &APPS[i], tile_w, tile_h);
|
|
|
|
lv_obj_add_flag(menu_container, LV_OBJ_FLAG_HIDDEN);
|
|
}
|
|
|
|
// One generic "coming soon" screen, retitled per app in stub_show_for(). The
|
|
// four future apps (Soundpad/Session/Now Playing/Home) point here until built.
|
|
static void init_stub_screen(lv_obj_t* scr) {
|
|
stub_container = lv_obj_create(scr);
|
|
lv_obj_set_size(stub_container, L.scr_w, L.scr_h);
|
|
lv_obj_set_pos(stub_container, 0, 0);
|
|
lv_obj_set_style_bg_opa(stub_container, LV_OPA_TRANSP, 0);
|
|
lv_obj_set_style_border_width(stub_container, 0, 0);
|
|
lv_obj_set_style_pad_all(stub_container, 0, 0);
|
|
lv_obj_clear_flag(stub_container, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
stub_title = make_screen_title(stub_container, "");
|
|
|
|
stub_icon = lv_label_create(stub_container);
|
|
lv_label_set_text(stub_icon, LV_SYMBOL_SETTINGS);
|
|
lv_obj_set_style_text_font(stub_icon, &lv_font_montserrat_28, 0);
|
|
lv_obj_set_style_text_color(stub_icon, COL_DIM, 0);
|
|
lv_obj_align(stub_icon, LV_ALIGN_CENTER, 0, -20);
|
|
|
|
lv_obj_t* soon = lv_label_create(stub_container);
|
|
lv_label_set_text(soon, "coming soon");
|
|
lv_obj_set_style_text_font(soon, &font_styrene_20, 0);
|
|
lv_obj_set_style_text_color(soon, COL_DIM, 0);
|
|
lv_obj_align(soon, LV_ALIGN_CENTER, 0, 30);
|
|
|
|
lv_obj_add_flag(stub_container, LV_OBJ_FLAG_HIDDEN);
|
|
}
|
|
|
|
static void init_bt_screen(lv_obj_t* scr) {
|
|
bt_container = lv_obj_create(scr);
|
|
lv_obj_set_size(bt_container, L.scr_w, L.scr_h);
|
|
lv_obj_set_pos(bt_container, 0, 0);
|
|
lv_obj_set_style_bg_opa(bt_container, LV_OPA_TRANSP, 0);
|
|
lv_obj_set_style_border_width(bt_container, 0, 0);
|
|
lv_obj_set_style_pad_all(bt_container, 0, 0);
|
|
lv_obj_clear_flag(bt_container, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
make_screen_title(bt_container, "Bluetooth");
|
|
|
|
bt_status_lbl = lv_label_create(bt_container);
|
|
lv_label_set_text(bt_status_lbl, "");
|
|
lv_obj_set_style_text_font(bt_status_lbl, &font_styrene_28, 0);
|
|
lv_obj_align(bt_status_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 20);
|
|
|
|
bt_name_lbl = lv_label_create(bt_container);
|
|
lv_label_set_text(bt_name_lbl, "");
|
|
lv_obj_set_style_text_font(bt_name_lbl, &font_styrene_20, 0);
|
|
lv_obj_set_style_text_color(bt_name_lbl, COL_DIM, 0);
|
|
lv_obj_align(bt_name_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 80);
|
|
|
|
bt_mac_lbl = lv_label_create(bt_container);
|
|
lv_label_set_text(bt_mac_lbl, "");
|
|
lv_obj_set_style_text_font(bt_mac_lbl, &font_styrene_20, 0);
|
|
lv_obj_set_style_text_color(bt_mac_lbl, COL_DIM, 0);
|
|
lv_obj_align(bt_mac_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 120);
|
|
|
|
lv_obj_add_flag(bt_container, LV_OBJ_FLAG_HIDDEN);
|
|
}
|
|
|
|
static void bt_refresh(void) {
|
|
if (!bt_container) return;
|
|
lv_label_set_text(bt_status_lbl, s_ble_connected ? "Connected" : "Waiting");
|
|
lv_obj_set_style_text_color(bt_status_lbl, s_ble_connected ? COL_GREEN : COL_AMBER, 0);
|
|
lv_label_set_text_fmt(bt_name_lbl, "%s", ble_get_device_name());
|
|
lv_label_set_text_fmt(bt_mac_lbl, "%s", ble_get_mac_address());
|
|
}
|
|
|
|
static void stub_show_for(screen_t s) {
|
|
const AppEntry* app = app_for_screen(s);
|
|
if (app) {
|
|
lv_label_set_text(stub_title, app->label);
|
|
lv_label_set_text(stub_icon, app->symbol);
|
|
}
|
|
}
|
|
|
|
// ======== Public API ========
|
|
|
|
void ui_init(void) {
|
|
compute_layout(board_caps());
|
|
|
|
lv_obj_t* scr = lv_screen_active();
|
|
lv_obj_set_style_bg_color(scr, COL_BG, 0);
|
|
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
|
|
|
|
init_icon_dsc_rgb565a8(&logo_dsc, LOGO_WIDTH, LOGO_HEIGHT, logo_data);
|
|
init_battery_icons();
|
|
|
|
init_usage_screen(scr);
|
|
splash_init(scr);
|
|
|
|
if (splash_get_root()) {
|
|
lv_obj_add_event_cb(splash_get_root(), global_click_cb, LV_EVENT_CLICKED, NULL);
|
|
}
|
|
|
|
// v2 screens — created hidden, shown on navigation.
|
|
init_menu_screen(scr);
|
|
init_stub_screen(scr);
|
|
init_bt_screen(scr);
|
|
|
|
logo_img = lv_image_create(scr);
|
|
lv_image_set_src(logo_img, &logo_dsc);
|
|
lv_obj_set_pos(logo_img, L.margin, L.title_y - 10);
|
|
lv_obj_add_flag(logo_img, LV_OBJ_FLAG_CLICKABLE); // tap the Claude logo → launcher
|
|
lv_obj_set_ext_click_area(logo_img, 14);
|
|
lv_obj_add_event_cb(logo_img, logo_click_cb, LV_EVENT_CLICKED, NULL);
|
|
|
|
// Back chevron — shared across the menu/app screens, hidden on home/splash.
|
|
nav_back_btn = lv_label_create(scr);
|
|
lv_label_set_text(nav_back_btn, LV_SYMBOL_LEFT);
|
|
lv_obj_set_style_text_font(nav_back_btn, &lv_font_montserrat_28, 0);
|
|
lv_obj_set_style_text_color(nav_back_btn, COL_TEXT, 0);
|
|
lv_obj_set_pos(nav_back_btn, L.margin, L.title_y);
|
|
lv_obj_add_flag(nav_back_btn, LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_set_ext_click_area(nav_back_btn, 18);
|
|
lv_obj_add_event_cb(nav_back_btn, nav_back_cb, LV_EVENT_CLICKED, NULL);
|
|
lv_obj_add_flag(nav_back_btn, LV_OBJ_FLAG_HIDDEN);
|
|
|
|
battery_img = lv_image_create(scr);
|
|
lv_image_set_src(battery_img, &battery_dscs[0]);
|
|
lv_obj_set_pos(battery_img, L.scr_w - 48 - L.margin, L.title_y);
|
|
}
|
|
|
|
void ui_update(const UsageData* data) {
|
|
if (!data->valid) return;
|
|
last_data_ms = lv_tick_get(); // a valid usage update just landed → dot goes green
|
|
data_received = true;
|
|
|
|
int s_pct = (int)(data->session_pct + 0.5f);
|
|
|
|
lv_label_set_text_fmt(lbl_session_pct, "%d%%", s_pct);
|
|
lv_bar_set_value(bar_session, s_pct, LV_ANIM_ON);
|
|
lv_obj_set_style_bg_color(bar_session, pct_color(data->session_pct), LV_PART_INDICATOR);
|
|
|
|
char buf[48];
|
|
format_reset_time(data->session_reset_mins, buf, sizeof(buf));
|
|
lv_label_set_text(lbl_session_reset, buf);
|
|
|
|
int w_pct = (int)(data->weekly_pct + 0.5f);
|
|
lv_label_set_text_fmt(lbl_weekly_pct, "%d%%", w_pct);
|
|
lv_bar_set_value(bar_weekly, w_pct, LV_ANIM_ON);
|
|
lv_obj_set_style_bg_color(bar_weekly, pct_color(data->weekly_pct), LV_PART_INDICATOR);
|
|
|
|
format_reset_time(data->weekly_reset_mins, buf, sizeof(buf));
|
|
lv_label_set_text(lbl_weekly_reset, buf);
|
|
}
|
|
|
|
// Pick the usage-view sub-screen: pairing hint (BLE down), the idle "Zzz" screen
|
|
// (connected but data has gone stale), or the live usage panels. Only re-lays-out
|
|
// on an actual change. The animated status line stays visible everywhere — it
|
|
// reads "Listening…" on the idle screen, keeping it alive rather than frozen.
|
|
static void update_view_state(void) {
|
|
if (!usage_group || !pair_group || !idle_group) return;
|
|
int v;
|
|
if (!s_ble_connected) {
|
|
v = 0; // pairing hint
|
|
} else if (data_received && (lv_tick_get() - last_data_ms) < DATA_FRESH_MS) {
|
|
v = 2; // live usage
|
|
} else {
|
|
v = 1; // idle / Zzz
|
|
}
|
|
if (v == view_state) return;
|
|
view_state = v;
|
|
lv_obj_add_flag(pair_group, LV_OBJ_FLAG_HIDDEN);
|
|
lv_obj_add_flag(idle_group, LV_OBJ_FLAG_HIDDEN);
|
|
lv_obj_add_flag(usage_group, LV_OBJ_FLAG_HIDDEN);
|
|
lv_obj_clear_flag(v == 0 ? pair_group : v == 1 ? idle_group : usage_group,
|
|
LV_OBJ_FLAG_HIDDEN);
|
|
}
|
|
|
|
void ui_tick_anim(void) {
|
|
if (current_screen != SCREEN_USAGE) return;
|
|
update_view_state();
|
|
if (view_state == 1) splash_mini_tick(); // animate the sleeping creature on the idle screen
|
|
|
|
uint32_t now = lv_tick_get();
|
|
|
|
if (now - anim_msg_start >= ANIM_MSG_MS) {
|
|
anim_msg_idx = (anim_msg_idx + 1) % ANIM_MSG_COUNT;
|
|
anim_msg_start = now;
|
|
}
|
|
|
|
if (now - anim_last_ms < spinner_ms[anim_spinner_idx]) return;
|
|
anim_last_ms = now;
|
|
anim_phase = (anim_phase + 1) % SPINNER_PHASES;
|
|
anim_spinner_idx = (anim_phase < SPINNER_COUNT) ? anim_phase
|
|
: (SPINNER_PHASES - anim_phase);
|
|
|
|
// Status text by priority. Whimsical messages only when connected & settled.
|
|
const char* text;
|
|
if (!s_ble_connected) {
|
|
text = "Waiting"; // advertising / waiting for a host connection
|
|
} else if (view_state == 1) { // idle — alternate so it reads as alive AND data-less
|
|
text = (anim_msg_idx & 1) ? "No data" : "Listening";
|
|
} else if (now - connected_at_ms < 5000) {
|
|
text = "Connected";
|
|
} else {
|
|
text = anim_messages[anim_msg_idx];
|
|
}
|
|
|
|
// All states share the whimsical style: "<glyph> <Title-case word>…"
|
|
static char buf[80];
|
|
snprintf(buf, sizeof(buf), "%s %s\xE2\x80\xA6",
|
|
spinner_frames[anim_spinner_idx], text);
|
|
lv_label_set_text(lbl_anim, buf);
|
|
}
|
|
|
|
static screen_t prev_non_splash_screen = SCREEN_USAGE;
|
|
static void apply_battery_visibility(void) {
|
|
if (!battery_img) return;
|
|
if (current_screen == SCREEN_SPLASH) lv_obj_add_flag(battery_img, LV_OBJ_FLAG_HIDDEN);
|
|
else lv_obj_clear_flag(battery_img, LV_OBJ_FLAG_HIDDEN);
|
|
}
|
|
|
|
// Tapping the splash/animations screen returns to the home (usage) screen.
|
|
static void global_click_cb(lv_event_t* e) {
|
|
(void)e;
|
|
if (current_screen == SCREEN_SPLASH) ui_show_screen(SCREEN_USAGE);
|
|
}
|
|
|
|
// The Claude logo on the home screen opens the launcher.
|
|
static void logo_click_cb(lv_event_t* e) {
|
|
(void)e;
|
|
ui_show_screen(SCREEN_MENU);
|
|
}
|
|
|
|
// Back chevron: from the launcher → home; from any app screen → launcher.
|
|
static void nav_back_cb(lv_event_t* e) {
|
|
(void)e;
|
|
ui_show_screen(current_screen == SCREEN_MENU ? SCREEN_USAGE : SCREEN_MENU);
|
|
}
|
|
|
|
// Top-left chrome: the Claude logo (tap → menu) on home, the back chevron on the
|
|
// screens below it, neither on the splash.
|
|
static void update_nav_chrome(screen_t screen) {
|
|
bool show_logo = (screen == SCREEN_USAGE);
|
|
bool show_back = (screen != SCREEN_USAGE && screen != SCREEN_SPLASH);
|
|
if (logo_img) {
|
|
if (show_logo) lv_obj_clear_flag(logo_img, LV_OBJ_FLAG_HIDDEN);
|
|
else lv_obj_add_flag(logo_img, LV_OBJ_FLAG_HIDDEN);
|
|
}
|
|
if (nav_back_btn) {
|
|
if (show_back) lv_obj_clear_flag(nav_back_btn, LV_OBJ_FLAG_HIDDEN);
|
|
else lv_obj_add_flag(nav_back_btn, LV_OBJ_FLAG_HIDDEN);
|
|
}
|
|
}
|
|
|
|
void ui_show_screen(screen_t screen) {
|
|
lv_obj_add_flag(usage_container, LV_OBJ_FLAG_HIDDEN);
|
|
if (menu_container) lv_obj_add_flag(menu_container, LV_OBJ_FLAG_HIDDEN);
|
|
if (stub_container) lv_obj_add_flag(stub_container, LV_OBJ_FLAG_HIDDEN);
|
|
if (bt_container) lv_obj_add_flag(bt_container, LV_OBJ_FLAG_HIDDEN);
|
|
splash_hide();
|
|
|
|
switch (screen) {
|
|
case SCREEN_SPLASH: splash_show(); break;
|
|
case SCREEN_USAGE: lv_obj_clear_flag(usage_container, LV_OBJ_FLAG_HIDDEN); break;
|
|
case SCREEN_MENU: lv_obj_clear_flag(menu_container, LV_OBJ_FLAG_HIDDEN); break;
|
|
case SCREEN_BLUETOOTH: bt_refresh(); lv_obj_clear_flag(bt_container, LV_OBJ_FLAG_HIDDEN); break;
|
|
case SCREEN_SOUNDPAD:
|
|
case SCREEN_SESSION:
|
|
case SCREEN_NOWPLAYING:
|
|
case SCREEN_HOMEASSIST:
|
|
stub_show_for(screen);
|
|
lv_obj_clear_flag(stub_container, LV_OBJ_FLAG_HIDDEN);
|
|
break;
|
|
default: break;
|
|
}
|
|
|
|
if (screen != SCREEN_SPLASH) prev_non_splash_screen = screen;
|
|
current_screen = screen;
|
|
update_nav_chrome(screen);
|
|
apply_battery_visibility();
|
|
}
|
|
|
|
void ui_toggle_splash(void) {
|
|
if (current_screen == SCREEN_SPLASH) ui_show_screen(prev_non_splash_screen);
|
|
else ui_show_screen(SCREEN_SPLASH);
|
|
}
|
|
|
|
screen_t ui_get_current_screen(void) {
|
|
return current_screen;
|
|
}
|
|
|
|
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;
|
|
s_ble_connected = (state == BLE_STATE_CONNECTED);
|
|
|
|
if (s_ble_connected && !was_connected) connected_at_ms = lv_tick_get();
|
|
// pair / idle / usage — picked from connection + data freshness.
|
|
update_view_state();
|
|
}
|
|
|
|
void ui_update_battery(int percent, bool charging) {
|
|
int idx;
|
|
if (charging) {
|
|
idx = 4;
|
|
} else if (percent < 0) {
|
|
idx = 0;
|
|
} else if (percent <= 10) {
|
|
idx = 0;
|
|
} else if (percent <= 35) {
|
|
idx = 1;
|
|
} else if (percent <= 75) {
|
|
idx = 2;
|
|
} else {
|
|
idx = 3;
|
|
}
|
|
lv_image_set_src(battery_img, &battery_dscs[idx]);
|
|
apply_battery_visibility();
|
|
}
|