feat(ui): trail Units setting — km/h, mph, min/km, min/mi

Second row in the Config view: UP/DOWN moves focus between Min dist
and Units, LEFT/RIGHT cycles the focused row's value. Units propagates
to the Summary view, which now shows either "Avg: N km/h" / "N mph"
or "Pace: M:SS /km" / "/mi" depending on the choice.

Pace renders as "--:-- /unit" when distance or elapsed time is zero
(avoids divide-by-zero glitches with one-point trails). mph conversion
uses the 0.621371 ratio with rounding; pace works in floating point
off raw metres and seconds for accuracy at low speeds.

NodePrefs gains trail_units_idx as another reserved-ish byte; old saves
load with 0 = km/h, no schema bump needed since the rd() lambda already
defaults missing fields to zero.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-05-25 13:16:02 +02:00
parent 72fbbf2400
commit 46ac292b36
4 changed files with 91 additions and 14 deletions

View File

@@ -307,6 +307,7 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
rd(_prefs.favourite_contacts, sizeof(_prefs.favourite_contacts));
rd(&_prefs.trail_interval_idx, sizeof(_prefs.trail_interval_idx));
rd(&_prefs.trail_min_delta_idx, sizeof(_prefs.trail_min_delta_idx));
rd(&_prefs.trail_units_idx, sizeof(_prefs.trail_units_idx));
// Schema sentinel: bumped on layout changes. Mismatch means an older file
// (or a different schema); rd() already zero-inits any fields not present,
@@ -407,6 +408,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)_prefs.favourite_contacts, sizeof(_prefs.favourite_contacts));
file.write((uint8_t *)&_prefs.trail_interval_idx, sizeof(_prefs.trail_interval_idx));
file.write((uint8_t *)&_prefs.trail_min_delta_idx, sizeof(_prefs.trail_min_delta_idx));
file.write((uint8_t *)&_prefs.trail_units_idx, sizeof(_prefs.trail_units_idx));
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL.
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;

View File

@@ -103,6 +103,7 @@ struct NodePrefs { // persisted to file
// not a persisted preference.
uint8_t trail_interval_idx; // reserved — sampling cadence is now fixed at TrailStore::SAMPLING_SECS
uint8_t trail_min_delta_idx; // indexes TrailStore::minDeltaMeters (5/10/25/100 m)
uint8_t trail_units_idx; // indexes TrailStore::unitLabel (km/h, mph, min/km, min/mi)
// Tail sentinel written at the end of /new_prefs. Bump the low byte when
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so

View File

@@ -40,6 +40,21 @@ public:
return L[idx < MIN_DELTA_COUNT ? idx : 0];
}
// Speed / pace display units. UNITS_KMH / UNITS_MPH show speed; UNITS_PACE_KM
// / UNITS_PACE_MI show time per distance ("pace"). Index 0 = km/h default.
enum Units : uint8_t {
UNITS_KMH = 0,
UNITS_MPH = 1,
UNITS_PACE_KM = 2,
UNITS_PACE_MI = 3,
};
static const uint8_t UNITS_COUNT = 4;
static const char* unitLabel(uint8_t idx) {
static const char* L[UNITS_COUNT] = { "km/h", "mph", "min/km", "min/mi" };
return L[idx < UNITS_COUNT ? idx : 0];
}
static bool unitIsPace(uint8_t idx) { return idx == UNITS_PACE_KM || idx == UNITS_PACE_MI; }
bool isActive() const { return _active; }
void setActive(bool a) {
if (a && !_active) {

View File

@@ -20,7 +20,12 @@ class TrailScreen : public UIScreen {
uint8_t _view = V_CONFIG;
int _summary_scroll = 0; // top visible item index in Summary view
int _list_scroll = 0; // top visible row in List view (newest = row 0)
bool _cfg_dirty = false; // unsaved Min-dist edits; flushed on start or back
bool _cfg_dirty = false; // unsaved Config edits; flushed on start or back
// Selected row in the Config view. UP/DOWN moves between rows;
// LEFT/RIGHT cycles the value of the focused row.
enum CfgRow { CFG_MIN_DIST = 0, CFG_UNITS = 1, CFG_ROW_COUNT };
uint8_t _cfg_sel = CFG_MIN_DIST;
static const int SUMMARY_ITEM_COUNT = 5;
@@ -79,8 +84,10 @@ public:
if (_view == V_CONFIG) {
NodePrefs* p = _task->getNodePrefs();
if ((c == KEY_LEFT || c == KEY_PREV) && p) { cycleMinDelta(p, -1); return true; }
if ((c == KEY_RIGHT || c == KEY_NEXT) && p) { cycleMinDelta(p, +1); return true; }
if (c == KEY_UP && _cfg_sel > 0) { _cfg_sel--; return true; }
if (c == KEY_DOWN && _cfg_sel < CFG_ROW_COUNT - 1) { _cfg_sel++; return true; }
if ((c == KEY_LEFT || c == KEY_PREV) && p) { cycleCfg(p, -1); return true; }
if ((c == KEY_RIGHT || c == KEY_NEXT) && p) { cycleCfg(p, +1); return true; }
if (c == KEY_ENTER) {
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; }
_store->setActive(true);
@@ -128,40 +135,92 @@ public:
}
private:
void cycleMinDelta(NodePrefs* p, int dir) {
uint8_t idx = p->trail_min_delta_idx;
if (idx >= TrailStore::MIN_DELTA_COUNT) idx = 0;
if (dir > 0) idx = (idx + 1) % TrailStore::MIN_DELTA_COUNT;
else idx = (idx + TrailStore::MIN_DELTA_COUNT - 1) % TrailStore::MIN_DELTA_COUNT;
p->trail_min_delta_idx = idx;
// Cycle the focused Config row's value. Dirty flag commits on Start / Back.
void cycleCfg(NodePrefs* p, int dir) {
if (_cfg_sel == CFG_MIN_DIST) {
uint8_t idx = p->trail_min_delta_idx;
if (idx >= TrailStore::MIN_DELTA_COUNT) idx = 0;
idx = (dir > 0) ? (idx + 1) % TrailStore::MIN_DELTA_COUNT
: (idx + TrailStore::MIN_DELTA_COUNT - 1) % TrailStore::MIN_DELTA_COUNT;
p->trail_min_delta_idx = idx;
} else if (_cfg_sel == CFG_UNITS) {
uint8_t idx = p->trail_units_idx;
if (idx >= TrailStore::UNITS_COUNT) idx = 0;
idx = (dir > 0) ? (idx + 1) % TrailStore::UNITS_COUNT
: (idx + TrailStore::UNITS_COUNT - 1) % TrailStore::UNITS_COUNT;
p->trail_units_idx = idx;
}
_cfg_dirty = true;
}
// Render the average speed (or pace) in the user-selected units.
void formatAvgPaceOrSpeed(char* buf, size_t n, NodePrefs* p) const {
uint8_t u = p ? p->trail_units_idx : 0;
if (u >= TrailStore::UNITS_COUNT) u = 0;
uint16_t kmh = _store->avgSpeedKmh();
if (u == TrailStore::UNITS_KMH) {
snprintf(buf, n, "Avg: %u km/h", (unsigned)kmh);
} else if (u == TrailStore::UNITS_MPH) {
// 1 km = 0.621371 mi
unsigned mph = (unsigned)((float)kmh * 0.621371f + 0.5f);
snprintf(buf, n, "Avg: %u mph", mph);
} else {
// Pace = elapsed / distance. Compute directly from raw totals for precision.
uint32_t d_m = _store->totalDistanceMeters();
uint32_t es_s = _store->elapsedSeconds();
const char* unit = (u == TrailStore::UNITS_PACE_KM) ? "/km" : "/mi";
if (d_m == 0 || es_s == 0) {
snprintf(buf, n, "Pace: --:-- %s", unit);
} else {
float dist_unit = (u == TrailStore::UNITS_PACE_KM) ? (d_m / 1000.0f)
: (d_m / 1609.344f);
if (dist_unit <= 0) { snprintf(buf, n, "Pace: --:-- %s", unit); return; }
float pace_min = (es_s / 60.0f) / dist_unit;
unsigned pm = (unsigned)pace_min;
unsigned ps = (unsigned)((pace_min - pm) * 60.0f);
snprintf(buf, n, "Pace: %u:%02u %s", pm, ps, unit);
}
}
}
void renderConfig(DisplayDriver& display) {
NodePrefs* p = _task->getNodePrefs();
const int y0 = display.listStart();
const int step = display.lineStep();
const int cw = display.getCharWidth();
char buf[24];
// Row 0 — Min dist
display.setCursor(0, y0);
display.print(_cfg_sel == CFG_MIN_DIST ? ">" : " ");
snprintf(buf, sizeof(buf), "Min dist: %s",
TrailStore::minDeltaLabel(p ? p->trail_min_delta_idx : 0));
display.setCursor(2, y0);
display.setCursor(cw + 2, y0);
display.print(buf);
// Row 1 — Units
display.setCursor(0, y0 + step);
display.print(_cfg_sel == CFG_UNITS ? ">" : " ");
snprintf(buf, sizeof(buf), "Units: %s",
TrailStore::unitLabel(p ? p->trail_units_idx : 0));
display.setCursor(cw + 2, y0 + step);
display.print(buf);
// Existing trail summary (if any) so the user sees what Enter will resume.
if (!_store->empty()) {
snprintf(buf, sizeof(buf), "Stored: %d pts", _store->count());
display.setCursor(2, y0 + step * 2);
display.setCursor(2, y0 + step * 3);
display.print(buf);
uint32_t d = _store->totalDistanceMeters();
if (d < 1000) snprintf(buf, sizeof(buf), "Dist: %lu m", (unsigned long)d);
else snprintf(buf, sizeof(buf), "Dist: %lu.%02lu km",
(unsigned long)(d / 1000), (unsigned long)((d % 1000) / 10));
display.setCursor(2, y0 + step * 3);
display.setCursor(2, y0 + step * 4);
display.print(buf);
} else {
display.setCursor(2, y0 + step * 2);
display.setCursor(2, y0 + step * 3);
display.print("No trail yet.");
}
}
@@ -197,7 +256,7 @@ private:
break;
}
case 4:
snprintf(buf, n, "Avg speed: %u km/h", (unsigned)_store->avgSpeedKmh());
formatAvgPaceOrSpeed(buf, n, _task->getNodePrefs());
break;
default:
buf[0] = '\0';