Files
MeshCore-Solo/examples/companion_radio/ui-lvgl/ClockScreen.h
T
JakubandClaude Opus 5.5 1b7402a33e style(ui-lvgl): selected / on in the full accent, no ACCENT_DIM
ACCENT_DIM (the accent mixed toward black) came out brown on selected
tabs, chips, segments and own bubbles. They take the accent with dark
text now, as primary actions and the quick panel tiles do:
styleSelected() sets the fill, the pressed shade and every label under
it. Own bubbles: dark text, age and delivery mark (green or red can't be
read on amber); the melody editor's selected note is outlined, the
sounding one filled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:35:46 +02:00

513 lines
22 KiB
C++

#pragma once
// Clock tools (Home > Clock): alarm, countdown timer, stopwatch and this
// month's calendar -- ui-new's Tools > Clock on a touch screen (the calendar
// as ui-new's e-ink has it under the clock). The alarm (NodePrefs alarm_*) and the
// countdown run in the Core's ClockEngine, so they fire on any screen and while
// the display sleeps; the stopwatch is view state here, as in ui-new. Whatever
// fires brings up a full-screen card with Dismiss (silent: the L2 speaker has
// no driver yet).
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp after NavMap.h.
namespace clockview {
enum : uint8_t { TAB_ALARM, TAB_TIMER, TAB_STOPWATCH, TAB_CALENDAR, TAB_COUNT };
enum : uint8_t { ACT_START_STOP, ACT_RESET };
static uint8_t s_tab = TAB_ALARM;
static uint8_t s_timer_h = 0, s_timer_m = 5, s_timer_s = 0; // countdown to start
static bool s_sw_running = false;
static uint32_t s_sw_start = 0, s_sw_accum = 0;
static bool s_shown_running = false; // timer state the screen was built for
static lv_obj_t* s_big = nullptr; // timer remaining / stopwatch reading
static lv_obj_t* s_go_lbl = nullptr; // Start / Stop
static lv_obj_t* s_ring = nullptr; // "Timer done" card on the top layer
static lv_obj_t* s_ring_lbl = nullptr;
static lv_obj_t* s_alarm_sw = nullptr; // follows a time change (setting it arms the alarm)
static struct tm s_cal_tm; // the day the calendar was drawn for (local)
static uint32_t s_cal_marks = 0; // its alarm days, bit (day - 1)
static lv_obj_t* s_cal = nullptr;
// The month as a grid, weeks from Monday: the weekday names, today in an
// accent box, the alarm's days with an accent bar under them, the weekend
// dimmer.
static void onCalendarDraw(lv_event_t* e) {
lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e);
lv_layer_t* layer = lv_event_get_layer(e);
lv_area_t a;
lv_obj_get_coords(o, &a);
const struct tm& t = s_cal_tm;
static const char* const NAMES[] = { "Mo", "Tu", "We", "Th", "Fr", "Sa", "Su" };
const int W = a.x2 - a.x1 + 1, cw = W / 7, x0 = a.x1 + (W - 7 * cw) / 2;
const int head = 16, rows = calmath::weeks(t), ch = (a.y2 - a.y1 + 1 - head) / rows;
lv_draw_label_dsc_t td;
lv_draw_label_dsc_init(&td);
td.align = LV_TEXT_ALIGN_CENTER;
td.font = THEME_FONT_SMALL;
for (int c = 0; c < 7; c++) {
td.color = lv_color_hex(theme::TEXT_MUTED);
td.text = NAMES[c];
lv_area_t la = { x0 + c * cw, a.y1, x0 + (c + 1) * cw - 1, a.y1 + head - 1 };
lv_draw_label(layer, &td, &la);
}
td.font = THEME_FONT_BODY;
const int fh = lv_font_get_line_height(td.font), col0 = calmath::firstCol(t), n = calmath::daysIn(t.tm_year, t.tm_mon);
char buf[3];
for (int day = 1; day <= n; day++) {
const int k = col0 + day - 1, cx = x0 + (k % 7) * cw, cy = a.y1 + head + (k / 7) * ch;
const bool today = day == t.tm_mday;
if (today) {
lv_draw_rect_dsc_t rd;
lv_draw_rect_dsc_init(&rd);
rd.radius = theme::RADIUS_SM;
rd.bg_color = lv_color_hex(theme::ACCENT);
const int bh = ch - 2 < fh + 2 ? ch - 2 : fh + 2, by = cy + (ch - bh) / 2;
lv_area_t ra = { cx + 4, by, cx + cw - 5, by + bh - 1 };
lv_draw_rect(layer, &rd, &ra);
}
snprintf(buf, sizeof(buf), "%d", day);
td.text = buf;
td.color = lv_color_hex(today ? theme::BG : k % 7 >= 5 ? theme::TEXT_MUTED : theme::TEXT);
lv_area_t la = { cx, cy + (ch - fh) / 2, cx + cw - 1, cy + (ch + fh) / 2 };
lv_draw_label(layer, &td, &la);
if (s_cal_marks & (1UL << (day - 1))) { // under the number
lv_draw_rect_dsc_t md;
lv_draw_rect_dsc_init(&md);
md.radius = 1;
md.bg_color = lv_color_hex(today ? theme::BG : theme::ACCENT);
lv_area_t ma = { cx + cw / 2 - 6, cy + ch - 2, cx + cw / 2 + 6, cy + ch - 1 };
lv_draw_rect(layer, &md, &ma);
}
}
}
static uint32_t swElapsed() { return s_sw_accum + (s_sw_running ? millis() - s_sw_start : 0); }
// Countdown: rounds up, so "00:01" shows until it actually fires.
static void fmtRemaining(char* b, int n, uint32_t ms) {
uint32_t t = (ms + 999) / 1000;
if (t >= 3600) snprintf(b, n, "%lu:%02lu:%02lu", (unsigned long)(t / 3600), (unsigned long)(t / 60 % 60), (unsigned long)(t % 60));
else snprintf(b, n, "%02lu:%02lu", (unsigned long)(t / 60), (unsigned long)(t % 60));
}
static void fmtStopwatch(char* b, int n, uint32_t ms) {
uint32_t t = ms / 1000;
if (t >= 3600) snprintf(b, n, "%lu:%02lu:%02lu", (unsigned long)(t / 3600), (unsigned long)(t / 60 % 60), (unsigned long)(t % 60));
else snprintf(b, n, "%02lu:%02lu.%lu", (unsigned long)(t / 60), (unsigned long)(t % 60), (unsigned long)(ms / 100 % 10));
}
// A number wheel, 0..n-1 in a loop with the middle row selected: an infinite
// lv_roller's look and touch, drawn by hand. lv_roller measures its whole
// option text (repeated three times) on every style change, layout pass and
// frame: some 40 of the ~100 ms this screen took to open went on that.
struct Wheel {
uint8_t n;
int32_t pos; // the selected row, in 1/256 rows: dragging moves it between rows
int32_t vel; // the finger's recent speed, px per input read (smoothed)
int32_t drag; // px the finger has moved since it went down
};
static const int32_t WHEEL_LINE = 4; // between rows
static char s_nums[60][3]; // "00".."59"
static int32_t wheelPitch() { return lv_font_get_line_height(THEME_FONT_TITLE) + WHEEL_LINE; }
static Wheel* wheelOf(lv_obj_t* o) { return (Wheel*)lv_obj_get_user_data(o); }
static int wheelValue(lv_obj_t* o) {
const Wheel* w = wheelOf(o);
int32_t r = (w->pos + 128) >> 8;
return (int)(((r % w->n) + w->n) % w->n);
}
static void wheelAnim(void* o, int32_t v) {
wheelOf((lv_obj_t*)o)->pos = v;
lv_obj_invalidate((lv_obj_t*)o);
}
static void wheelAnimDone(lv_anim_t* a) { // back to the first loop, so pos never grows
Wheel* w = wheelOf((lv_obj_t*)a->var);
w->pos = (int32_t)wheelValue((lv_obj_t*)a->var) << 8;
}
// Rolls to row `to` (whole rows from where it is now) and says so.
static void wheelGo(lv_obj_t* o, int32_t to) {
Wheel* w = wheelOf(o);
int32_t from = w->pos, end = to * 256;
lv_anim_delete(o, wheelAnim);
lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_var(&a, o);
lv_anim_set_exec_cb(&a, wheelAnim);
lv_anim_set_values(&a, from, end);
int32_t rows = LV_ABS(end - from) >> 8;
lv_anim_set_duration(&a, LV_MIN(200 + rows * 20, 600));
lv_anim_set_path_cb(&a, lv_anim_path_ease_out);
lv_anim_set_completed_cb(&a, wheelAnimDone);
w->pos = end; // the value is the target already: read by the event below
lv_obj_send_event(o, LV_EVENT_VALUE_CHANGED, NULL);
w->pos = from;
lv_anim_start(&a);
}
static void wheelDraw(lv_event_t* e) {
lv_obj_t* o = lv_event_get_current_target_obj(e);
const Wheel* w = wheelOf(o);
lv_layer_t* layer = lv_event_get_layer(e);
lv_area_t a;
lv_obj_get_coords(o, &a);
int32_t pitch = wheelPitch(), mid = (a.y1 + a.y2 + 1) / 2;
int32_t h_main = lv_font_get_line_height(THEME_FONT_TITLE), h_sel = lv_font_get_line_height(THEME_FONT_LARGE);
int32_t band_h = (h_main + h_sel) / 2 + WHEEL_LINE;
lv_area_t band = { a.x1, mid - band_h / 2, a.x2, mid - band_h / 2 + band_h - 1 };
lv_draw_rect_dsc_t rd;
lv_draw_rect_dsc_init(&rd);
rd.bg_color = lv_color_hex(theme::SURFACE_2);
lv_draw_rect(layer, &rd, &band);
// Above the band, the band, below it: the band's rows in the larger font.
const lv_area_t clips[3] = { { a.x1, a.y1, a.x2, band.y1 - 1 }, band, { a.x1, band.y2 + 1, a.x2, a.y2 } };
const lv_area_t clip_ori = layer->_clip_area;
int32_t base = w->pos >> 8, frac = w->pos & 255;
int32_t span = lv_area_get_height(&a) / 2 / pitch + 2;
for (int c = 0; c < 3; c++) {
if (!lv_area_intersect(&layer->_clip_area, &clip_ori, &clips[c])) continue;
bool sel = c == 1;
lv_draw_label_dsc_t td;
lv_draw_label_dsc_init(&td);
td.font = sel ? THEME_FONT_LARGE : THEME_FONT_TITLE;
td.color = lv_color_hex(sel ? theme::ACCENT : theme::TEXT_MUTED);
td.align = LV_TEXT_ALIGN_CENTER;
int32_t fh = sel ? h_sel : h_main;
for (int32_t k = -span; k <= span; k++) {
int32_t y = mid + ((k << 8) - frac) * pitch / 256; // the row's middle
if (y + fh < layer->_clip_area.y1 || y - fh > layer->_clip_area.y2) continue;
td.text = s_nums[(((base + k) % w->n) + w->n) % w->n];
lv_area_t ta = { a.x1, y - fh / 2, a.x2, y - fh / 2 + fh - 1 };
lv_draw_label(layer, &td, &ta);
}
}
layer->_clip_area = clip_ori;
}
static void wheelEvent(lv_event_t* e) {
lv_obj_t* o = lv_event_get_current_target_obj(e);
Wheel* w = wheelOf(o);
lv_indev_t* indev = lv_indev_active();
switch (lv_event_get_code(e)) {
case LV_EVENT_PRESSED:
lv_anim_delete(o, wheelAnim);
w->vel = w->drag = 0;
break;
case LV_EVENT_PRESSING: {
lv_point_t v = { 0, 0 };
if (indev) lv_indev_get_vect(indev, &v);
w->vel = (w->vel + v.y) / 2;
w->drag += LV_ABS(v.y);
if (v.y) {
w->pos -= v.y * 256 / wheelPitch();
lv_obj_invalidate(o);
}
break;
}
case LV_EVENT_RELEASED:
case LV_EVENT_PRESS_LOST: {
int32_t pitch = wheelPitch(), to;
if (w->drag > 4) { // a throw carries on for ~10 reads' worth (as lv_roller's), then the nearest row
to = (w->pos - w->vel * 10 * 256 / pitch + 128) >> 8;
} else { // a tap: that row to the middle
lv_point_t p = { 0, 0 };
if (indev) lv_indev_get_point(indev, &p);
lv_area_t a;
lv_obj_get_coords(o, &a);
int32_t dy = p.y - (a.y1 + a.y2 + 1) / 2;
to = ((w->pos + 128) >> 8) + (dy + (dy < 0 ? -pitch / 2 : pitch / 2)) / pitch;
}
wheelGo(o, to);
break;
}
case LV_EVENT_DELETE:
lv_anim_delete(o, wheelAnim);
lv_free(w);
break;
default: break;
}
}
static lv_obj_t* wheel(lv_obj_t* parent, uint8_t n, int sel, lv_event_cb_t cb, uintptr_t which) {
if (!s_nums[0][0]) for (int i = 0; i < 60; i++) snprintf(s_nums[i], sizeof(s_nums[i]), "%02d", i);
Wheel* w = (Wheel*)lv_malloc(sizeof(Wheel));
*w = { n, (int32_t)(sel % n) * 256, 0, 0 };
lv_obj_t* o = lv_obj_create(parent);
styleSurface(o, theme::SURFACE);
lv_obj_set_style_radius(o, theme::RADIUS_SM, 0);
lv_obj_remove_flag(o, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_SCROLL_CHAIN));
lv_obj_set_size(o, 60, 84); // three rows; leaves room for the button below
lv_obj_set_user_data(o, w);
lv_obj_add_event_cb(o, wheelDraw, LV_EVENT_DRAW_MAIN, NULL);
lv_obj_add_event_cb(o, wheelEvent, LV_EVENT_ALL, NULL);
lv_obj_add_event_cb(o, cb, LV_EVENT_VALUE_CHANGED, (void*)which);
return o;
}
static lv_obj_t* colon(lv_obj_t* parent) { return label(parent, ":", THEME_FONT_LARGE, theme::TEXT_MUTED); }
} // namespace clockview
static void onOpenClock(lv_event_t* e) { (void)e; s_ui->showClock(); }
static void onClockTab(lv_event_t* e) { s_ui->clockTab((int)(uintptr_t)lv_event_get_user_data(e)); }
static void onClockAct(lv_event_t* e) { s_ui->clockAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
static void onRingDismiss(lv_event_t* e) { (void)e; s_ui->dismissRing(); }
// Alarm: hour / minute wheels, on switch, repeat. Saved on every change.
static void onAlarmWheel(lv_event_t* e) {
s_ui->setAlarm((int)(uintptr_t)lv_event_get_user_data(e), clockview::wheelValue((lv_obj_t*)lv_event_get_target(e)));
}
static void onAlarmSwitch(lv_event_t* e) {
s_ui->setAlarm(2, lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED) ? 1 : 0);
}
static void onAlarmRepeat(lv_event_t* e) {
s_ui->setAlarm(3, choiceSelected((lv_obj_t*)lv_event_get_target(e)));
}
static void onTimerWheel(lv_event_t* e) {
int v = clockview::wheelValue((lv_obj_t*)lv_event_get_target(e));
switch ((uintptr_t)lv_event_get_user_data(e)) {
case 0: clockview::s_timer_h = (uint8_t)v; break;
case 1: clockview::s_timer_m = (uint8_t)v; break;
default: clockview::s_timer_s = (uint8_t)v; break;
}
}
void UITask::showClock() {
_screen = SCR_CLOCK;
buildClock();
}
void UITask::clockTab(int tab) {
if (tab < 0 || tab >= clockview::TAB_COUNT) return;
clockview::s_tab = (uint8_t)tab;
buildClock();
}
void UITask::buildClock() {
using namespace clockview;
lv_obj_t* body = newScreen("Clock", true);
lv_obj_set_style_pad_row(body, 6, 0);
lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE);
s_big = s_go_lbl = s_alarm_sw = s_cal = nullptr;
// Tabs
lv_obj_t* tabs = centerRow(body);
static const char* const NAMES[TAB_COUNT] = { "Alarm", "Timer", "Stopwatch", "Calendar" };
for (int t = 0; t < TAB_COUNT; t++) {
lv_obj_t* b = lv_button_create(tabs);
lv_obj_set_height(b, 32);
lv_obj_set_flex_grow(b, 1);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_radius(b, theme::RADIUS, 0);
lv_obj_add_event_cb(b, onClockTab, LV_EVENT_CLICKED, (void*)(uintptr_t)t);
lv_obj_center(label(b, NAMES[t], THEME_FONT_SMALL, theme::TEXT));
styleSelected(b, t == s_tab);
}
if (s_tab == TAB_ALARM) {
lv_obj_t* r = centerRow(body);
wheel(r, 24, _prefs->alarm_hour % 24, onAlarmWheel, 0);
colon(r);
wheel(r, 60, _prefs->alarm_min % 60, onAlarmWheel, 1);
lv_obj_t* col = lv_obj_create(r);
styleSurface(col, theme::BG);
lv_obj_remove_flag(col, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(col, 120, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_set_style_pad_row(col, 4, 0);
lv_obj_set_style_pad_left(col, 8, 0);
lv_obj_t* on = flexBox(col, LV_FLEX_FLOW_ROW); // the switch and its label: one line
lv_obj_set_flex_align(on, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(on, 8, 0);
lv_obj_set_style_pad_bottom(on, 4, 0);
lv_obj_t* sw = s_alarm_sw = lv_switch_create(on);
label(on, "Alarm on", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_set_size(sw, 50, 26);
if (_prefs->alarm_on) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, onAlarmSwitch, LV_EVENT_VALUE_CHANGED, NULL);
label(col, "Repeat", THEME_FONT_SMALL, theme::TEXT_MUTED);
char opts[48];
int o = 0;
for (uint8_t i = 0; i < NodePrefs::ALARM_REPEAT_COUNT; i++)
o += snprintf(opts + o, sizeof(opts) - o, "%s%s", i ? "\n" : "", i ? NodePrefs::alarmRepeatLabel(i) : "Once");
lv_obj_t* dd = choiceCreate(col, opts, NodePrefs::alarmRepeatIdxForMask(_prefs->alarm_repeat_mask), "Repeat");
lv_obj_set_width(dd, 112);
lv_obj_add_event_cb(dd, onAlarmRepeat, LV_EVENT_VALUE_CHANGED, NULL);
bool synced = rtc_clock.getCurrentTime() > 1000000000UL;
label(body, synced ? "Rings at this local time (Settings time zone)." : "The clock isn't set yet: the alarm waits for a time sync.",
THEME_FONT_SMALL, theme::TEXT_MUTED);
return;
}
if (s_tab == TAB_CALENDAR) {
struct tm t;
bool synced = localTime(_prefs, t);
if (!synced) {
label(body, "The clock isn't set yet: no date to show.", THEME_FONT_BODY, theme::TEXT_MUTED);
return;
}
static const char* const LONG_MONTHS[] = { "January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December" };
char title[24]; // the month on the header's right: the grid gets the height
snprintf(title, sizeof(title), "%s %d", LONG_MONTHS[t.tm_mon], t.tm_year + 1900);
if (_header) lv_obj_align(label(_header, title, THEME_FONT_TITLE, theme::TEXT_MUTED), LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
s_cal_tm = t;
s_cal_marks = calmath::alarmDays(t, _prefs->alarm_on, _prefs->alarm_repeat_mask, _prefs->alarm_hour, _prefs->alarm_min);
s_cal = lv_obj_create(body);
lv_obj_remove_style_all(s_cal);
lv_obj_remove_flag(s_cal, LV_OBJ_FLAG_CLICKABLE);
fillWidth(s_cal);
lv_obj_set_flex_grow(s_cal, 1);
lv_obj_add_event_cb(s_cal, onCalendarDraw, LV_EVENT_DRAW_MAIN_END, NULL);
return;
}
if (s_tab == TAB_TIMER) {
s_shown_running = _core->clock.isTimerRunning();
if (s_shown_running) {
s_big = label(body, "", THEME_FONT_CLOCK, theme::TEXT);
lv_obj_set_width(s_big, LV_PCT(100));
lv_obj_set_style_text_align(s_big, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_pad_ver(s_big, 18, 0);
} else {
lv_obj_t* r = centerRow(body);
wheel(r, 24, s_timer_h, onTimerWheel, 0);
colon(r);
wheel(r, 60, s_timer_m, onTimerWheel, 1);
colon(r);
wheel(r, 60, s_timer_s, onTimerWheel, 2);
}
} else { // stopwatch
s_big = label(body, "", THEME_FONT_CLOCK, theme::TEXT);
lv_obj_set_width(s_big, LV_PCT(100));
lv_obj_set_style_text_align(s_big, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_pad_ver(s_big, 18, 0);
}
lv_obj_t* acts = centerRow(body);
lv_obj_t* go = lv_button_create(acts);
lv_obj_set_size(go, 150, 40);
lv_obj_set_style_shadow_width(go, 0, 0);
lv_obj_set_style_radius(go, theme::RADIUS, 0);
lv_obj_add_event_cb(go, onClockAct, LV_EVENT_CLICKED, (void*)(uintptr_t)ACT_START_STOP);
s_go_lbl = label(go, "", THEME_FONT_BODY, theme::TEXT);
lv_obj_center(s_go_lbl);
stylePrimary(go);
if (s_tab == TAB_STOPWATCH) {
lv_obj_t* rs = lv_button_create(acts);
lv_obj_set_size(rs, 100, 40);
lv_obj_set_style_shadow_width(rs, 0, 0);
lv_obj_set_style_radius(rs, theme::RADIUS, 0);
lv_obj_set_style_bg_color(rs, lv_color_hex(theme::SURFACE), 0);
lv_obj_add_event_cb(rs, onClockAct, LV_EVENT_CLICKED, (void*)(uintptr_t)ACT_RESET);
lv_obj_center(label(rs, LV_SYMBOL_REFRESH " Reset", THEME_FONT_BODY, theme::TEXT));
}
refreshClock();
}
// From loop() while SCR_CLOCK is shown: the running readouts.
void UITask::refreshClock() {
using namespace clockview;
if (s_tab == TAB_CALENDAR) { // past midnight, or the alarm changed: a new day to box or mark
struct tm t;
if (s_cal && localTime(_prefs, t)
&& (t.tm_mday != s_cal_tm.tm_mday || t.tm_mon != s_cal_tm.tm_mon
|| calmath::alarmDays(t, _prefs->alarm_on, _prefs->alarm_repeat_mask, _prefs->alarm_hour, _prefs->alarm_min) != s_cal_marks))
buildClock();
return;
}
if (s_tab == TAB_TIMER) {
bool running = _core->clock.isTimerRunning();
if (running != s_shown_running) { buildClock(); return; } // started elsewhere / just fired
if (s_big) {
char b[16];
fmtRemaining(b, sizeof(b), _core->clock.timerRemainingMs());
setText(s_big, b);
}
if (s_go_lbl) setText(s_go_lbl, running ? LV_SYMBOL_STOP " Stop" : LV_SYMBOL_PLAY " Start");
} else if (s_tab == TAB_STOPWATCH) {
if (s_big) {
char b[16];
fmtStopwatch(b, sizeof(b), swElapsed());
setText(s_big, b);
}
if (s_go_lbl) setText(s_go_lbl, s_sw_running ? LV_SYMBOL_PAUSE " Stop" : LV_SYMBOL_PLAY " Start");
}
}
void UITask::clockAction(uint8_t act) {
using namespace clockview;
if (s_tab == TAB_TIMER) {
if (_core->clock.isTimerRunning()) _core->clock.stopTimer();
else {
uint32_t ms = ((uint32_t)s_timer_h * 3600 + s_timer_m * 60 + s_timer_s) * 1000UL;
if (ms == 0) { showToast("Set a time first"); return; }
_core->clock.startTimer(ms);
}
buildClock();
} else if (s_tab == TAB_STOPWATCH) {
if (act == ACT_RESET) { s_sw_accum = 0; s_sw_start = millis(); }
else if (s_sw_running) { s_sw_accum += millis() - s_sw_start; s_sw_running = false; }
else { s_sw_start = millis(); s_sw_running = true; }
refreshClock();
}
}
// which: 0 hour, 1 minute, 2 on/off, 3 repeat preset.
void UITask::setAlarm(int which, int v) {
if (!_prefs) return;
switch (which) {
case 0: _prefs->alarm_hour = (uint8_t)v; _prefs->alarm_on = 1; break; // setting a time arms it
case 1: _prefs->alarm_min = (uint8_t)v; _prefs->alarm_on = 1; break;
case 2: _prefs->alarm_on = (uint8_t)v; break;
case 3: _prefs->alarm_repeat_mask = NodePrefs::alarmRepeatMaskForIdx((uint8_t)v); break;
}
_core->clock.onAlarmChanged();
prefsSave();
if (which < 2 && clockview::s_alarm_sw) lv_obj_add_state(clockview::s_alarm_sw, LV_STATE_CHECKED);
}
// ── Ring card (any screen) ────────────────────────────────────────────────────
void UITask::showRing(const char* text) {
using namespace clockview;
wake();
if (!s_ring) {
s_ring = lv_obj_create(lv_layer_top());
lv_obj_remove_style_all(s_ring);
lv_obj_set_size(s_ring, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(s_ring, lv_color_hex(theme::BG), 0);
lv_obj_set_style_bg_opa(s_ring, LV_OPA_COVER, 0);
lv_obj_add_flag(s_ring, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_flex_flow(s_ring, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(s_ring, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(s_ring, 14, 0);
label(s_ring, LV_SYMBOL_BELL, THEME_FONT_LARGE, theme::ACCENT);
s_ring_lbl = label(s_ring, "", THEME_FONT_LARGE, theme::TEXT);
lv_obj_t* b = lv_button_create(s_ring);
lv_obj_set_size(b, 200, 56);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_radius(b, theme::RADIUS, 0);
lv_obj_add_event_cb(b, onRingDismiss, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(b, "Dismiss", THEME_FONT_LARGE, theme::TEXT));
stylePrimary(b);
}
lv_label_set_text(s_ring_lbl, text);
lv_obj_remove_flag(s_ring, LV_OBJ_FLAG_HIDDEN);
lv_obj_move_foreground(s_ring); // over the lock screen too
}
void UITask::dismissRing() {
stopMelody();
_core->clock.dismissRing();
hideRing();
}
void UITask::hideRing() {
if (clockview::s_ring) lv_obj_add_flag(clockview::s_ring, LV_OBJ_FLAG_HIDDEN);
if (_screen == SCR_CLOCK) buildClock(); // e.g. the timer's Start again
}