#include "ui.h" #include "app_state.h" #include "audio.h" #include "config.h" #include #include #include #include // --------------------------------------------------------------------------- // Display: draw into a 128x128 off-screen canvas (32 KB) and push the whole // frame at once, so animations don't flicker. // --------------------------------------------------------------------------- static Arduino_DataBus *bus = new Arduino_HWSPI(PIN_LCD_DC, PIN_LCD_CS, PIN_SPI_SCK, PIN_SPI_MOSI, PIN_SPI_MISO); static Arduino_GFX *panel = new Arduino_ST7735(bus, PIN_LCD_RST, 0, true, 128, 128, LCD_COL_OFFSET, LCD_ROW_OFFSET, LCD_COL_OFFSET, LCD_ROW_OFFSET); static Arduino_Canvas *gfx = new Arduino_Canvas(128, 128, panel); static CRGB leds[NUM_LEDS]; // Palette static const uint16_t C_BG = RGB565(0, 0, 0); static const uint16_t C_TEXT = RGB565(235, 235, 235); static const uint16_t C_DIM = RGB565(110, 110, 120); static const uint16_t C_FAINT = RGB565(40, 40, 48); static const uint16_t C_RED = RGB565(255, 60, 60); static const uint16_t C_GREEN = RGB565(60, 220, 110); static const uint16_t C_BLUE = RGB565(60, 150, 255); static const uint16_t C_CYAN = RGB565(40, 220, 230); static const uint16_t C_PURPLE = RGB565(180, 110, 255); static const uint16_t C_AMBER = RGB565(255, 170, 40); // Fonts (U8g2 font set, rendered by Arduino_GFX) #define F_SMALL u8g2_font_5x8_tr #define F_MED u8g2_font_helvB10_tr #define F_BIG u8g2_font_logisoso22_tn static float levelHistory[32]; static uint8_t levelPos = 0; bool uiBegin() { FastLED.addLeds(leds, NUM_LEDS); FastLED.setBrightness(LED_BRIGHTNESS); FastLED.clear(true); SpiLock lock; if (!gfx->begin()) return false; gfx->fillScreen(C_BG); gfx->flush(); pinMode(PIN_LCD_BL, OUTPUT); digitalWrite(PIN_LCD_BL, HIGH); return true; } // ---------- drawing helpers ---------- static void textCentered(const char *s, int y, const uint8_t *font, uint16_t color) { int16_t x1, y1; uint16_t w, h; gfx->setFont(font); gfx->getTextBounds(s, 0, y, &x1, &y1, &w, &h); gfx->setTextColor(color); gfx->setCursor((128 - (int)w) / 2 - x1, y); gfx->print(s); } // Word-wrap into lines of the 5x8 font (24 chars across), centered. static void textWrapped(const char *s, int y, uint16_t color, int maxLines) { const int cols = 24; char line[cols + 1]; int len = strlen(s), start = 0; for (int n = 0; n < maxLines && start < len; n++) { while (s[start] == ' ') start++; int end = min(start + cols, len); if (end < len) { int sp = end; while (sp > start && s[sp] != ' ') sp--; if (sp > start) end = sp; } memcpy(line, s + start, end - start); line[end - start] = 0; textCentered(line, y + n * 10, F_SMALL, color); start = end; } } static void drawCheck(int cx, int cy, uint16_t color) { gfx->fillCircle(cx, cy, 18, color); for (int d = -1; d <= 1; d++) { gfx->drawLine(cx - 9, cy + d, cx - 3, cy + 6 + d, C_BG); gfx->drawLine(cx - 3, cy + 6 + d, cx + 9, cy - 6 + d, C_BG); } } static void drawCross(int cx, int cy, uint16_t color) { gfx->fillCircle(cx, cy, 18, color); for (int d = -1; d <= 1; d++) { gfx->drawLine(cx - 7 + d, cy - 7, cx + 7 + d, cy + 7, C_BG); gfx->drawLine(cx + 7 + d, cy - 7, cx - 7 + d, cy + 7, C_BG); } } static void drawMic(int cx, int cy, uint16_t color) { gfx->fillRoundRect(cx - 7, cy - 16, 14, 24, 7, color); gfx->fillArc(cx, cy - 2, 13, 11, 0, 180, color); gfx->fillRect(cx - 1, cy + 11, 3, 6, color); gfx->fillRect(cx - 7, cy + 16, 15, 2, color); } // 12 dots around a circle with a fading tail - mirrors the LED ring spinner. static void drawSpinner(int cx, int cy, uint16_t color, uint32_t t) { int head = (t / 80) % 12; for (int i = 0; i < 12; i++) { float a = i * (TWO_PI / 12) - HALF_PI; int age = (head - i + 12) % 12; uint16_t c = age == 0 ? color : age < 4 ? C_DIM : C_FAINT; gfx->fillCircle(cx + cosf(a) * 20, cy + sinf(a) * 20, age == 0 ? 4 : 3, c); } } static void drawProgress(int y, uint8_t pct, uint16_t color) { gfx->drawRoundRect(8, y, 112, 12, 4, C_DIM); int w = 108 * pct / 100; if (w > 0) gfx->fillRoundRect(10, y + 2, w, 8, 3, color); } static void drawStatusBar() { char buf[16]; gfx->setFont(F_SMALL); gfx->setTextColor(C_DIM); gfx->setCursor(3, 10); if (timeIsValid()) { time_t now = time(nullptr); strftime(buf, sizeof(buf), "%H:%M", localtime(&now)); gfx->print(buf); } else { gfx->print("--:--"); } // Wi-Fi bars bool wifi = WiFi.status() == WL_CONNECTED; for (int i = 0; i < 3; i++) { int h = 3 + i * 2; gfx->fillRect(108 + i * 4, 10 - h, 3, h, wifi ? C_GREEN : C_FAINT); } if (!wifi && (millis() / 500) % 2) gfx->fillRect(108, 8, 3, 2, C_AMBER); // SD card uint16_t sd = app.state == ST_NO_SD ? C_RED : C_DIM; gfx->fillRect(97, 3, 7, 8, sd); gfx->fillTriangle(102, 3, 104, 3, 104, 5, C_BG); gfx->drawFastHLine(0, 14, 128, C_FAINT); } // "mm:ss" under an hour, "h:mm:ss" from then on. static void formatDuration(char *buf, size_t len, uint32_t secs) { if (secs >= 3600) snprintf(buf, len, "%lu:%02lu:%02lu", secs / 3600, secs / 60 % 60, secs % 60); else snprintf(buf, len, "%02lu:%02lu", secs / 60, secs % 60); } static void footer(const char *s) { textCentered(s, 124, F_SMALL, C_DIM); } // Short label for a recording path: "/recordings/REC_2026-10-02_14-30-05.wav" -> "10-02 14:30:05" static String recLabel(const char *path) { const char *p = strstr(path, "REC_"); if (!p || strlen(p) < 23) return String(path); String s = String(p + 9, 5) + " " + String(p + 15, 8); s.replace('-', ':'); s.setCharAt(2, '-'); return s; } // ---------- screens ---------- static void drawScreen(uint32_t t) { char buf[48]; switch (app.state) { case ST_BOOT: drawMic(64, 52, C_BLUE); textCentered("VoiceNotes", 92, F_MED, C_TEXT); textCentered("Connecting Wi-Fi...", 108, F_SMALL, C_DIM); break; case ST_NO_SD: gfx->fillRoundRect(50, 30, 28, 36, 4, C_RED); gfx->fillTriangle(70, 30, 78, 30, 78, 38, C_BG); textCentered("No microSD", 88, F_MED, C_TEXT); textCentered("Insert a FAT32 card", 104, F_SMALL, C_DIM); break; case ST_IDLE: drawMic(64, 46, C_DIM); textCentered("Ready", 84, F_MED, C_TEXT); if (app.lastRecording[0]) { snprintf(buf, sizeof(buf), "Last %s", recLabel(app.lastRecording).c_str()); textCentered(buf, 100, F_SMALL, C_DIM); } footer("PLUS rec | hold: notes"); break; case ST_RECORDING: { if ((t / 500) % 2) gfx->fillCircle(46, 27, 5, C_RED); else gfx->drawCircle(46, 27, 5, C_RED); gfx->setFont(F_MED); gfx->setTextColor(C_RED); gfx->setCursor(56, 32); gfx->print("REC"); uint32_t sec = recorderSeconds(); formatDuration(buf, sizeof(buf), sec); textCentered(buf, 66, F_BIG, C_TEXT); // Rolling level meter, one bar per frame. levelHistory[levelPos] = app.micLevel; levelPos = (levelPos + 1) % 32; for (int i = 0; i < 32; i++) { float v = levelHistory[(levelPos + i) % 32]; int h = max(1, (int)(v * 30)); gfx->fillRect(i * 4, 100 - h / 2 - 1, 3, h, v > 0.85f ? C_AMBER : C_RED); } uint32_t left = app.maxSeconds - min(sec, (uint32_t)app.maxSeconds); if (left < 60) snprintf(buf, sizeof(buf), "BOOT stop | %lus left", left); else snprintf(buf, sizeof(buf), "BOOT: stop"); footer(buf); break; } case ST_SAVED: drawCheck(64, 44, C_GREEN); textCentered("Saved", 82, F_MED, C_TEXT); textCentered(recLabel(app.lastRecording).c_str(), 96, F_SMALL, C_DIM); formatDuration(buf, sizeof(buf), app.recordedBytes / (SAMPLE_RATE * 2)); snprintf(buf + strlen(buf), sizeof(buf) - strlen(buf), " %.1f MB", app.recordedBytes / 1048576.0f); textCentered(buf, 106, F_SMALL, C_DIM); footer("hold PLUS: notes"); break; case ST_UPLOADING: // Arrow-up-into-cloud gfx->fillCircle(54, 42, 10, C_BLUE); gfx->fillCircle(70, 38, 13, C_BLUE); gfx->fillRoundRect(42, 40, 44, 14, 6, C_BLUE); gfx->fillTriangle(64, 30, 57, 40, 71, 40, C_BG); gfx->fillRect(62, 40, 5, 10, C_BG); textCentered("Uploading", 76, F_MED, C_TEXT); drawProgress(86, app.progress, C_BLUE); if (app.partCount > 1) snprintf(buf, sizeof(buf), "Part %u of %u %u%%", app.partIndex, app.partCount, app.progress); else snprintf(buf, sizeof(buf), "%u%%", app.progress); textCentered(buf, 110, F_SMALL, C_DIM); break; case ST_TRANSCRIBING: drawSpinner(64, 44, C_CYAN, t); textCentered("Transcribing", 84, F_MED, C_TEXT); if (app.partCount > 1) snprintf(buf, sizeof(buf), "Part %u of %u", app.partIndex, app.partCount); else snprintf(buf, sizeof(buf), "speech -> text"); textCentered(buf, 100, F_SMALL, C_DIM); break; case ST_SUMMARIZING: drawSpinner(64, 44, C_PURPLE, t); textCentered("Summarizing", 84, F_MED, C_TEXT); if (app.received) snprintf(buf, sizeof(buf), "Claude: %lu chars", app.received); else snprintf(buf, sizeof(buf), "Claude is thinking..."); textCentered(buf, 100, F_SMALL, C_DIM); break; case ST_SYNCING: drawSpinner(64, 44, C_GREEN, t); textCentered("Saving", 84, F_MED, C_TEXT); textCentered("to Nextcloud", 100, F_SMALL, C_DIM); break; case ST_DONE: drawCheck(64, 44, C_GREEN); textCentered("Notes saved", 82, F_MED, C_TEXT); textWrapped(app.detail, 96, C_DIM, 2); break; case ST_ERROR: drawCross(64, 40, C_RED); textCentered("Error", 74, F_MED, C_TEXT); textWrapped(app.detail, 88, C_DIM, 4); break; } } // ---------- LED ring ---------- static void spin(CRGB color, uint32_t t, uint16_t periodMs, int comets) { fadeToBlackBy(leds, NUM_LEDS, 70); int head = (t / (periodMs / NUM_LEDS)) % NUM_LEDS; for (int c = 0; c < comets; c++) leds[(head + c * NUM_LEDS / comets) % NUM_LEDS] = color; } static void updateLeds(uint32_t t) { uint32_t inState = millis() - app.stateSince; switch (app.state) { case ST_BOOT: spin(CRGB(255, 120, 0), t, 800, 1); break; case ST_NO_SD: fill_solid(leds, NUM_LEDS, (t / 700) % 2 ? CRGB(120, 0, 0) : CRGB::Black); break; case ST_IDLE: { uint8_t b = beatsin8(12, 4, 28); // slow "breathing" CRGB c = WiFi.status() == WL_CONNECTED ? CRGB(b / 2, b / 2, b) : CRGB(b, b / 2, 0); fill_solid(leds, NUM_LEDS, c); break; } case ST_RECORDING: { // Dim red base, plus a VU meter that grows around the ring with the voice level. fill_solid(leds, NUM_LEDS, CRGB(beatsin8(40, 10, 40), 0, 0)); float lit = app.micLevel * NUM_LEDS; for (int i = 0; i < NUM_LEDS; i++) { if (i < (int)lit) leds[i] = CRGB(255, 0, 0); else if (i == (int)lit) leds[i] = CRGB((uint8_t)(255 * (lit - i)), 0, 0); } break; } case ST_SAVED: { uint8_t b = inState < 1500 ? 255 - inState / 6 : 5; fill_solid(leds, NUM_LEDS, CRGB(0, b, 0)); break; } case ST_UPLOADING: { // Fill the ring with overall progress across all parts. uint8_t parts = max((uint8_t)1, app.partCount); uint8_t done = app.partIndex ? app.partIndex - 1 : 0; int full = (done * 100 + app.progress) * NUM_LEDS / (parts * 100); for (int i = 0; i < NUM_LEDS; i++) leds[i] = i < full ? CRGB(0, 60, 255) : i == full && (t / 250) % 2 ? CRGB(0, 30, 120) : CRGB::Black; break; } case ST_TRANSCRIBING: spin(CRGB(0, 200, 220), t, 900, 1); break; case ST_SUMMARIZING: spin(CRGB(150, 0, 255), t, 1200, 2); break; case ST_SYNCING: spin(CRGB(0, 220, 80), t, 900, 1); break; case ST_DONE: fill_solid(leds, NUM_LEDS, CRGB(0, beatsin8(30, 20, 200), 40)); break; case ST_ERROR: fill_solid(leds, NUM_LEDS, (t / 200) % 2 && inState < 2400 ? CRGB(255, 0, 0) : CRGB(40, 0, 0)); break; } FastLED.show(); } void uiUpdate() { static uint32_t lastFrame = 0; uint32_t t = millis(); if (t - lastFrame < 50) return; lastFrame = t; updateLeds(t); gfx->fillScreen(C_BG); drawStatusBar(); drawScreen(t); SpiLock lock; gfx->flush(); }