Skip to main content

The same app, twice

One pomodoro timer — 25 minutes of focus, a draining ring, tap to start — written both ways. Both versions below are real: the Dart one is examples/ui/pomodoro.dart and runs on the ESP32-S3 panel; the C one is examples/comparison/pomodoro_lvgl.c, compiles against LVGL release/v9.2 with zero warnings, and runs in LVGL's SDL simulator. Neither is a strawman — the LVGL version is written the way LVGL wants to be written.

moth on the left, LVGL on the right, both screenshotted from their simulators:

the same pomodoro in moth and LVGL

(The one visible difference is the clock: moth's is 72px from a subsetted face; LVGL's stock fonts stop at Montserrat 48, and going bigger means the offline font converter.)

The moth version

import 'package:moth/widgets.dart';

final workMs = 25 * 60 * 1000;
final breakMs = 5 * 60 * 1000;

final workAccent = 0xFFE8A33D; // amber: time to focus
final breakAccent = 0xFF33AA66; // green: time to stand up

class Pomodoro extends Component {
bool running = false;
bool onBreak = false;
int remainingMs = 25 * 60 * 1000;
int endMs = 0; // wall-clock end of the running session
int shownSec = -1; // last second painted

void toggle() {
setState(() {
running = !running;
if (running) endMs = millis() + remainingMs;
});
}

void tick(int now) {
if (!running) return;
remainingMs = endMs - now;
if (remainingMs <= 0) {
setState(() {
onBreak = !onBreak;
remainingMs = onBreak ? breakMs : workMs;
running = false;
});
return;
}
if (remainingMs ~/ 1000 != shownSec) {
setState(() {
shownSec = remainingMs ~/ 1000;
});
}
}

Widget build() {
var accent = onBreak ? breakAccent : workAccent;
var total = onBreak ? breakMs : workMs;
var m = remainingMs ~/ 60000;
var s = (remainingMs ~/ 1000) % 60;

return Container(
color: 0xFF0E0E12,
onTap: () => toggle(),
child: Stack(children: [
Container(
color: 0xFF0E0E12,
flex: 1,
padding: uiSafeArea(0),
child: Column(
mainAxisAlignment: mainAxisCenter,
crossAxisAlignment: crossAxisCenter,
spacing: 14,
children: [
Text(onBreak ? 'BREAK' : 'FOCUS',
style: TextStyle(fontSize: 20, color: accent)),
Text('$m:${s < 10 ? "0$s" : "$s"}',
style: TextStyle(fontSize: 72, color: 0xFFF2EFE7)),
Divider(thickness: 2, width: 180, color: 0xFF2A2A31),
Text(running ? 'TAP TO PAUSE' : 'TAP TO START',
style: TextStyle(fontSize: 14, color: 0xFF6B6B76)),
],
),
),
CircularProgressIndicator(
value: remainingMs / total,
strokeWidth: 8,
color: accent,
backgroundColor: 0xFF1C1C21,
size: uiWidth(),
),
]),
);
}
}

void main() {
var pomo = Pomodoro();
runApp(pomo);

var last = millis();
while (true) {
var now = millis();
pomo.tick(now);
pumpFrame(now - last);
last = now;
delay(50);
}
}

The LVGL version

The full file is examples/comparison/pomodoro_lvgl.c; this is its shape:

/* State lives in globals; the widgets that display it live in globals too,
* because callbacks need to reach both. */
static bool running;
static bool on_break;
static int32_t remaining_ms = WORK_MS;
static uint32_t end_ms;

static lv_obj_t *arc;
static lv_obj_t *phase_label;
static lv_obj_t *time_label;
static lv_obj_t *hint_label;

/* Every piece of state that changes must be manually pushed into each
* widget that shows it — this is the retained-mode contract. Forget one
* call here and that widget silently shows stale state. */
static void refresh(void) {
int32_t total = on_break ? BREAK_MS : WORK_MS;
int32_t sec = remaining_ms / 1000;

lv_label_set_text(phase_label, on_break ? "BREAK" : "FOCUS");
lv_obj_set_style_text_color(phase_label, accent(), 0);
lv_label_set_text_fmt(time_label, "%ld:%02ld", (long)(sec / 60),
(long)(sec % 60));
lv_label_set_text(hint_label, running ? "TAP TO PAUSE" : "TAP TO START");
lv_arc_set_value(arc, (int32_t)(((int64_t)remaining_ms * 100) / total));
lv_obj_set_style_arc_color(arc, accent(), LV_PART_INDICATOR);
}

void pomodoro_create(void) {
/* ...54 lines of lv_obj_create / lv_obj_set_style_* / lv_obj_set_flex_*
* building the tree that build() describes declaratively — including
* eight calls to turn lv_arc (an input widget) into a progress ring:
* rotate it, set its range, strip its knob, make it non-clickable. */
}

What actually differs

mothLVGL
Modeldeclarative — build() is the state; the reconciler diffs itretained — every change hand-routed to every widget showing it
The classic bug"forgot to update the label" is not expressiblea missed call in refresh() = silently stale widget
State topologyfields on one class, reached via this8 globals, because callbacks must reach state and widgets
The ringCircularProgressIndicator(value:)lv_arc styled out of being a slider, in eight calls
FontsfontSize: 72, faces subsetted offline by fontgenlv_conf.h edit + rebuild; stock ceiling is 48px
Round paneluiSafeArea()inscribed square computed by hand (329px)
Edit-to-screenmothc --push: 175ms over the cable, program onlyfull firmware flash — the UI is compiled into it
Line count113 with comments123, excluding bring-up and lv_conf.h

The pitch is not "less code" — the counts are nearly equal. It is which code: describing the UI versus plumbing state into it, and a 175ms push versus a firmware flash for every iteration.

What LVGL has that moth does not

Being honest cuts both ways. LVGL is a decade mature with a widget catalogue moth will not match for years (tables, charts, keyboards, spans, animations galore), theming, RTL and CJK text, binding ecosystems, and a huge community. moth trades all of that breadth for one bet: a beginner who knows Dart — or wants to learn the language Flutter apps are written in — can be productive on a microcontroller in an afternoon, with the programming model they will meet again on mobile. If you need LVGL's breadth today, use LVGL; it is excellent.