Commit Graph

3574 Commits

Author SHA1 Message Date
Jakub
58e07900db docs(readme): add Contributors section
Big thanks to vanous and marczykm; plus a nod to upstream MeshCore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v1.22
2026-07-06 21:07:31 +02:00
Jakub
8d8eace99c revert(eink): drop full refresh on screen change — too much black-flash
A full (non-partial) refresh on every screen change (638eea7b) turned out to be
far too aggressive on real e-ink hardware: every navigation black-flashes,
which is worse than the ghosting it was clearing. Remove the whole mechanism —
DisplayDriver::forceFullRefresh() virtual, GxEPDDisplay's _force_full flag and
override, the endFrame() branch, and the setCurrScreen() call. E-ink is back to
interval-only full refreshes (Settings > Full refresh interval).

The favourites "(gone)"-tile prune that shipped in the same commit is kept.

Builds green: WioTrackerL1Eink_companion_solo_dual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:58:51 +02:00
Jakub
51a0c46828 feat(ui): auto-enter room chat after a successful login
Logging in to a room server (first-time prompt or a remembered password) used
to leave the user on the room list, needing a second Enter to open the chat.
onRoomLoginResult() now opens the room history on success via a shared
openDmHistory() helper (extracted from the Enter-on-contact path).

The login result is async, so the auto-enter is gated on the user still being
on that room in the picker: phase CONTACT_PICK, room mode, no context menu /
share / pick-target sub-flow active, and the result pubkey matching the
selected contact. Otherwise it stays put (no yanking the user into a screen
they navigated away from).

Resolves the "auto-enter after login" code-review item. Builds green:
WioTrackerL1_companion_solo_dual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:42:05 +02:00
Jakub
8a9fb33a6a fix(ui): drop {loc}/{time} placeholders in the room password keyboard
The shared keyboard seeds {loc}/{time} on begin() for message composition, but
a room/repeater login password is not a message — a placeholder token there is
nonsensical and a footgun (picking one inserts literal "{time}" into the
password). Clear them right after opening the keyboard in both room-login paths
(context-menu Login… and the Enter-on-room prompt), matching the same fix
already applied to preset names and waypoint labels.

Builds green: WioTrackerL1_companion_solo_dual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:39:11 +02:00
Jakub
21a34451d9 refactor(ui): dedupe caps-shift and favourites "+" tile
Two duplications surfaced by a framework-consistency pass:

- KeyboardWidget applied the a-z shift-uppercase at five draw/commit sites
  with an inline `if (caps && ch >= 'a' && ch <= 'z')`. Fold them into one
  `kbApplyCaps(ch, caps)` helper.
- The favourites grid drew the empty "+" tile from two branches (the gone-slot
  prune and the always-empty slot). Route both through a single `has_contact`
  flag so the "+" and the trailing selection-colour reset each live at one site.

No behaviour change. Builds green: WioTrackerL1_companion_solo_dual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:18:36 +02:00
Jakub
866e44c4e2 feat(keyboard): show digit on each T9 key; complete v1.22 docs
T9 keys now read like a phone keypad — each cell is labelled <digit><group>
(e.g. 2abc), with the digit matching what the multi-tap cycle lands on after
the letters. No separator space: the widest group (".,!?'-") plus the digit
already fills a narrow 128px OLED cell at 3 columns.

Also fills in the v1.22 release notes (T9 keyboard, trail auto-save, Clock
Tools in Tools, Map/Shutdown reorderable, battery-read fix, the UI-audit fix
batch, discover-dedup, favourites self-heal, preset-name placeholder, OLED
frame-skip, e-ink full-refresh) and syncs the solo-feature docs: new Keyboard
settings section, Trail Auto-save row, and Clock Tools reachable from Tools.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:08:29 +02:00
Jakub
fbcc67f275 style(ui): drop dead if(found) guard in favourites badge
The favourites render already continues on !found (gone-slot prune), so the
badge block only runs when found is true. The if(found) guard around the
unread-badge lookup was always true — remove it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:51:56 +02:00
Jakub
638eea7bb0 feat(ui): full e-ink refresh on screen change + prune gone favourites
Two UI quick wins from the 2026-07-05 review:

- E-ink: force a full (non-partial) refresh on the first frame of a new
  screen. Inter-screen ghosting was the most visible cheap win — the
  N-partials interval alone doesn't catch navigation. New
  DisplayDriver::forceFullRefresh() (no-op on OLED), set in setCurrScreen()
  and consumed by GxEPDDisplay::endFrame().

- Favourites: clear a stale "(gone)" tile at render time so it reverts to an
  empty "+" slot. Happens when prefs outlive the contact list (e.g. a wiped
  /contacts3); onContactRemoved only catches a live delete. Pruned slots are
  persisted once per pass (self-healing — an emptied slot can't re-fire).

Builds green: WioTrackerL1_companion_solo_dual (OLED),
WioTrackerL1Eink_companion_solo_dual (e-ink).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:40:14 +02:00
Jakub
4ae3d22654 perf(oled): skip I2C flush when the frame is unchanged
The SH1106 path always pushed the full 1 KB buffer over I2C every endFrame,
even on static screens (clock, home) that don't change between updates. Hash
the GFX buffer (FNV-1a, no external dep — the CRC32 lib is only wired into
e-ink builds) and skip display.display() when it matches the last frame
pushed. _force_redraw forces a flush on the first frame and after
turnOn()/clear() so a post-wake or post-clear frame can't be wrongly skipped.

Mirrors the existing e-ink CRC-skip. Builds green: WioTrackerL1_companion_solo_dual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:40:01 +02:00
Jakub
9ee199e641 feat(trail): auto-save GPS trail on shutdown (Trail settings toggle)
Losing the whole route on a low-battery auto-shutdown was the worst solo-mode
failure. New NodePrefs::trail_autosave_lowbatt toggle (Tools > Trail >
Settings > Auto-save, default OFF): UITask::shutdown() writes the live trail
to /trail when the toggle is on and _trail.count() > 0. The count guard stops
an empty trail from wiping a previously saved one; it overwrites the same
/trail file the manual Trail > Save uses.

Schema bumped 0xC0DE001A -> 0xC0DE001B: append-only tail field with a load
clamp, so pre-0x1B saves default to off. sizeof(NodePrefs) is unchanged (the
byte fits existing padding after keyboard_type), so the tripwire assert stays
2496.

Builds green: WioTrackerL1_companion_solo_dual (RAM 69.9%, Flash 62.8%).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:21:42 +02:00
Jakub
046c2f258e fix(ui-orig): show hop count, not raw path_len, in message origin
The "(N)" origin readout printed the raw wire path_len. That byte packs the
path hash-size mode in its top 2 bits and the hop count in the low 6, so with
path_hash_mode >= 1 (2-byte hashes) it over-reported — e.g. "(66)" for a
2-hop message. Mask & 63 (getPathHashCount) so it shows the real hop count.

Affects the ui-orig variants only (ui-new/ui-tiny ignore the arg).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:21:31 +02:00
Jakub
399b61e2bb fix(companion): dedup node-discover responses heard more than once
A discover response often arrives twice — the zero-hop direct copy and a
re-flooded copy relayed by another repeater carry different packet hashes,
so the mesh duplicate filter passes both. The standalone scan appended the
same node twice and app-driven discover forwarded both copies, so one
repeater showed up as two.

Track (tag, responder pubkey) for a short window in isDupDiscoverResp() and
drop the second copy in both the standalone and app-forward paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:00:52 +02:00
Jakub
9a71ee14b6 fix(solo): six fixes from the 2026-07-05 UI audit
- locked device: a ringing alarm/timer was invisible — wakeForAlarm() now
  holds the lock wake window for the whole ring, the lock screen draws the
  alert overlay, and a key-dismissed ring falls back to a 5 s glance
- status bar: A / live-share / trail / repeater icons no longer vanish when
  Bluetooth is off (moved outside the isSerialEnabled() gate)
- alert overlay: long text wraps to up to 3 lines inside the box instead of
  overflowing the border (new UITask::renderAlertOverlay, shared with lock)
- MyMesh: force NUL on contact.name copied from an app frame (unterminated
  name could overrun the AdvertPath strcpy)
- clock tools: ring/timer deadline compares now wrap-safe (signed diff),
  matching the trail/loc-share timers
- messages: channel context menu freezes its target channel at open; fav
  toggle no longer retargets the menu when the fav-only filter drops the row

All three solo envs build green (WioTrackerL1 OLED/Eink, GAT562-30S).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:08:44 +02:00
liamcottle
57563ab8f9 update qr code docs 2026-07-06 02:04:56 +12:00
Jakub
04978d8ae9 feat(tools): add Clock Tools entry to Tools > System
Surfaces the Alarm/Timer/Stopwatch screen (previously reachable only
from the home Clock page) as a System tool, via the existing
UITask::gotoClockTools() and ICON_ALARM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:03:40 +02:00
Jakub
e63e260eab fix(ui): drop {loc}/{time} placeholders when naming a radio preset
The shared on-screen keyboard seeds {loc}/{time} placeholders on begin()
for message composition, but they make no sense in a radio-preset name.
Clear them right after opening the keyboard from the "+ Save current..."
flow in both Settings > Radio and Tools > Repeater; the message-slot
editor still keeps them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:02:43 +02:00
Jakub
7ff1b7c86b fix(wio-tracker-l1): battery voltage reads high/jittery — divider always on
The per-read VBAT_ENABLE gating from 4d46abb2 (energy optimizations)
powered the battery voltage divider HIGH for only 10ms before each ADC
read, then LOW. 10ms is too short for the high-impedance divider node
to settle before the nRF52 SAADC samples it, so readings came out high
and inconsistent (e.g. 4.6-5.0V on a LiPo that maxes ~4.2V).

Hold VBAT_ENABLE HIGH continuously again (as before 4d46abb2) so the
node is always settled at sample time, and drop the toggle + delay(10)
from getBattMilliVolts(). Standby cost is ~2uA on the 2x1M divider,
negligible next to the device's mA-level draw.

The CPU-sleep and LED-off energy optimizations from 4d46abb2 are left
untouched — those are the real savings; only the VBAT gating is reverted.
2026-07-03 09:51:32 +02:00
Jakub
3419e590fe fix(keyboard): rename QWERTY label to ABC (layout is alphabetical, not QWERTY)
The on-screen keyboard grid has been a-b-c...z alphabetical order since
it was first introduced, never an actual QWERTY row layout. The
"QWERTY" name only appeared with the T9 option (needed something to
contrast it against) and was inaccurate from the start.
2026-07-02 18:33:40 +02:00
Jakub
5fe9240fc5 Merge branch 'integrate/pr21-t9-keyboard'
Merges PR #21 (T9 on-screen keyboard option, marczykm) plus a follow-up
fix bumping SCHEMA_SENTINEL to 0xC0DE001A — the PR added a new persisted
field (keyboard_type) without bumping the schema version, which would
have silently misaligned every NodePrefs field after alarm_min for
devices already on the previous schema.
2026-07-02 18:05:18 +02:00
Jakub
b576de75b6 fix(prefs): bump schema sentinel for keyboard_type field (PR #21)
keyboard_type was inserted into the append-only NodePrefs layout without
bumping SCHEMA_SENTINEL, leaving it at 0xC0DE0019 — the same value plain
main already uses without this field. A device already on that schema
would silently misalign every field read after alarm_min once updated
to a build containing this change. Bump to 0xC0DE001A and fix the
migration comment's stale version label (it said 0xC0DE0018, which was
already taken by the MAP home-page migration).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 18:02:48 +02:00
Marcin Marczyk
5ed0a05265 Merge branch 'main' into feat/t9-onscreen-keyboard 2026-07-02 13:49:46 +02:00
marcin
3363867d98 Added T9 on-screen keyboard as an option 2026-07-02 12:21:52 +02:00
MarekZegare4
890fc89431 fix(prefs): update NodePrefs size tripwire after page_order growth
Growing page_order 11 → 13 bumped sizeof(NodePrefs) 2488 → 2496 (the 2 added
bytes plus alignment padding to the next 8-byte boundary). Save/load/clamp and
SCHEMA_SENTINEL were already updated; this syncs the static_assert. Verified
with a host compile (fixed-width members → layout matches the target).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:06:08 +02:00
MarekZegare4
948076560b feat(home-pages): make Map and Shutdown reorderable; v1.22 notes
The Map page had no Settings entry and Shutdown was pinned to the end of the
carousel because page_order held only 11 slots — full in the common
GPS+SENSORS config, so both pages fell back to being appended at nav time and
couldn't be moved. Grow page_order to 13 (== HPB_COUNT) so every page has a
reorderable slot, and add Map + Shutdown to the default order and the
required-append list.

To keep the persisted format backward-compatible (page_order sits mid-record,
not at the tail), the on-disk head stays the original 11 bytes at its existing
offset and the 2 new slots are appended at the file tail, matching the
append-only schema design. Bump SCHEMA_SENTINEL to 0xC0DE0019; on a pre-0x19
save the tail bytes are the old sentinel/EOF, so they're clamped to 0 and
ensurePageOrderInit re-appends Map/Shutdown into the freed slots on first use.

Drop the now-dead Shutdown-eviction path (13 slots fit all pages).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:01:40 +02:00
MarekZegare4
088bbe61ba fix(home-pages): stop Favourites re-enabling on update; add Map visibility toggle
The prefs schema-migration block runs on every SCHEMA_SENTINEL mismatch, and
the "turn Favourites on" step was ungated. Since each firmware release bumps
the sentinel, every update re-applied `home_pages_mask |= HP_FAVOURITES`,
clobbering a user who had hidden it. Gate it to the transition that added
Favourites (sentinel < 0xC0DE0002), matching the trail_units_idx migration.

The Map/Trail carousel page shipped with no visibility toggle: pageBit(MAP)
returned -1 so it was always visible with no Settings row. Add a proper "Map"
toggle (new HPB_MAP/HP_MAP bit, pageBit/bitToPage cases, HOME_MAP settings
row) plus a gated one-time migration (sentinel < 0xC0DE0018) that keeps it
visible for upgraders. Bump SCHEMA_SENTINEL to 0xC0DE0018; page_order[] stays
a literal 11 so the persisted layout is unchanged.

Also fix the fresh-install seed, which used a stale 0x01FF literal that left
Favourites hidden on new installs despite its "all pages visible" comment;
seed NodePrefs::HP_ALL so fresh installs match upgraders (Favourites + Map).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:34:38 +02:00
MarekZegare4
7e0dbe4afc docs: clock tools + e-ink input handling; v1.21 release notes
- release-notes v1.21: add Clock tools (alarm/timer/stopwatch) to What's new,
  e-ink button responsiveness + double-tap debounce to Fixes, and the input
  edge-capture/key-queue and hardware-timer ringtone notes to Under the hood.
- README: note clock tools on the Clock Screen entries.
- solo_ui_framework: document the hardware->handleInput path (mandatory
  begin() per button, IRQ edge capture, key queue + coalesced redraw).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v1.21
2026-06-30 22:40:44 +02:00
MarekZegare4
f31596da1a fix(eink): debounce edge capture + self-heal so one press isn't a double-tap
A single button press could surface as two CLICKs on the e-ink build, most
visibly as start+stop on the stopwatch. Two contact-bounce paths fed the
IRQ edge-capture machinery a phantom press/release pair:

- a bounce edge accepted just after a clean release was replayed as a real
  press — ISR_DEBOUNCE_MS (5 ms) was too short for the joystick switch; raised
  to 25 ms so the settling burst is swallowed.
- the live-pin self-heal reconciled prev against a single raw digitalRead,
  which can sample a bouncing contact mid-flap and synthesise a transition.
  It now acts only once the divergence has been stable for ISR_DEBOUNCE_MS, so
  a momentary read can't inject a click; a genuinely lost edge still heals
  (~25 ms later) so the button can't stick.

Both thresholds stay far below any human tap cadence (>100 ms), so rapid
multi-tap navigation still registers every tap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:40:44 +02:00
MarekZegare4
65e5b0af99 Merge branch 'fix/eink-button-irq-edge-capture' 2026-06-30 22:16:16 +02:00
MarekZegare4
78dba5e807 Merge branch 'main' into fix/eink-button-irq-edge-capture
# Conflicts:
#	examples/companion_radio/ui-new/UITask.cpp
2026-06-30 22:13:18 +02:00
MarekZegare4
8c70acc3ef fix(eink): queue input so a burst of taps survives a blocking refresh
The joystick directions and Back were never begin()'d, so on the e-ink build
they never claimed a GPIOTE channel — edges landing during a blocking panel
refresh were lost and the IRQ-capture work didn't reach them. begin() them.

Even with edges captured, rapid taps of one direction replayed into a single
check() and collapsed into a double/triple-click event the navigation handler
ignores, and the loop only ever dispatched one key per render. So:

- MomentaryButton: for multiclick=false buttons emit one CLICK per completed
  release (one per check() call) instead of collapsing — each tap stays a
  discrete key. multiclick=true buttons (double/triple) are unchanged.
- UITask: add a key FIFO; drain each direction fully into it, apply the whole
  queued burst, then redraw once. N taps captured during a refresh become N
  navigation steps at the cost of a single refresh. Also fixes losing a key
  when two buttons fire in the same loop iteration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:10:18 +02:00
MarekZegare4
697af2d435 Merge branch 'feat/clock-alarm-timer-stopwatch'
Clock Tools: one-shot alarm, countdown timer and stopwatch on the Clock
page, with the alarm/timer/ring engine in UITask (sync-robust absolute
alarm scheduling; millis-based timer/stopwatch).
2026-06-30 09:01:11 +02:00
MarekZegare4
9ce04835da feat(companion): clock tools — alarm, countdown timer, stopwatch
Add a Clock Tools screen (Enter on the home Clock page) with three time
utilities, plus the engine that drives them from UITask::loop() so they
work regardless of the current screen / display state:

- Alarm: a single one-shot wake alarm. Persisted in NodePrefs as a local
  time-of-day; UITask schedules it as an ABSOLUTE fire instant recomputed
  from that time, so it survives RTC re-syncs (mesh/app/GPS/CLI all jump
  the clock) — small corrections still fire on time, a jump over the
  target still fires (late, up to 6 h). Disarms after firing. A bell
  icon signals an armed alarm on the clock face and the top status bar.
- Timer: a millis-based countdown (sync-immune), big HH:MM:SS readout,
  rings even when off-screen.
- Stopwatch: millis-based, keeps running in the background.

Numeric fields use the shared framework DigitEditor (digit-by-digit,
min/max enforced). The alarm/timer/ring engine lives in UITask alongside
the locator/live-share engines (no screen-cast for time-critical logic).

E-ink: live readouts refresh coarsely (and on any key); timing is exact
regardless, and the countdown's buzzer fires on time. Rings override mute
and are dismissed by any key (auto-stop after 1 min). Cannot wake from a
full Shutdown (CPU/RAM powered down).

NodePrefs: +alarm_on/alarm_hour/alarm_min (fit existing tail padding,
sizeof unchanged 2488), SCHEMA_SENTINEL 0xC0DE0016 -> 0xC0DE0017,
DataStore read/clamp/write in lockstep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:01:03 +02:00
MarekZegare4
59d1722207 Merge branch 'refactor/quickmsg-history-store'
Extract the DM + channel message-history rings from QuickMsgScreen into a
self-contained MessageHistory store, dedupe the notif/melody override
accessors, share the history row-frame / [+ send] button between the two
history lists, and fix the [+ send] row vanishing in room/DM history.
Hardware-validated before merge.
2026-06-29 23:10:54 +02:00
MarekZegare4
116ee3e767 refactor(companion): share history row-frame + compose button across DM/channel
The DM/room and channel history lists drew each message box's selection frame
and the bottom-left [+ send] button with byte-identical inline code. Extract
two small static helpers (drawHistRowFrame, drawComposeButton) and route both
lists through them.

Only the parts that were already identical are unified — the divergent
per-type logic (sender parsing, ACK semantics, body handling) stays inline, so
this is behaviour-preserving. The DM compose button now also gets the channel
list's border (and its always-reset-to-LIGHT), folding in the earlier
[+ send] visibility fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:05:11 +02:00
MarekZegare4
efb6a8b629 fix(companion): keep [+ send] visible in DM/room history when not selected
In the DM/room message history, the message loop leaves the ink DARK when the
last drawn box is the selected one, so the unselected [+ send] row printed
dark-on-dark and vanished. Most visible in room servers, where there's always
a selected message. Reset to LIGHT before drawing the row (matching the
channel-history version). Pre-existing bug, independent of the history-store
refactor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 22:59:59 +02:00
MarekZegare4
17cc51e84d refactor(companion): dedupe notif/melody overrides via shared primitives
The eight per-contact/channel notification + melody accessors were four
near-identical copies of two storage shapes. Collapse them onto two
primitives:

- maskPairGet/Set — a 3-state packed in a (presence, variant) channel-index
  bitmask pair. The notif and melody uses disagree on which state the variant
  bit means, so the caller passes the state value for variant-set (v_set).
- prefTableGet/Set<Entry> — the {prefix[4], value} DM table find/update/clear/
  insert/overwrite-slot-0 logic, templated over the entry struct + a member
  pointer to its value field (DmNotifEntry::state / DmMelodyEntry::slot).

The eight accessors are now thin wrappers; behaviour identical (verified
state↔bit mappings). QuickMsgScreen 1738 -> 1715 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 22:48:10 +02:00
MarekZegare4
f6baa5c38c refactor(companion): extract MessageHistory store from QuickMsgScreen
Move the two RAM history rings (channel + DM) out of the 1988-line
QuickMsgScreen into a self-contained MessageHistory.h component, mirroring
the WaypointsView extraction from TrailScreen. The store owns the ring
buffers, per-entry delivery state (channel relay echo, DM end-to-end ACK +
auto-resend) and the per-channel unread counters; the screen keeps all
view state (selection, scroll, fullscreen readers, the unread "viewing
session" bookkeeping) and reaches entries through accessors.

Behaviour-preserving:
- The shared types (AckState, ChHistEntry, DmHistEntry, MSG_TEXT_BUF) are
  file-scope in MessageHistory.h, so the phase machine still refers to them
  unqualified — only data + storage logic moved.
- addChannelMsg keeps a thin screen forwarder that computes the "viewing"
  flag (a phase fact the store can't see) and returns the ring pos;
  afterSend uses armChannelRelay(pos, seq) instead of poking the ring.
- The public API called via UITask/MyMesh/the bot (addChannelMsg, addDMMsg,
  markDmDelivered, markChannelRelayed, tickDmResends, clearAllChannelUnread,
  getTotalChannelUnread, getRecentDMContacts) stays on QuickMsgScreen as
  forwarders, so no caller changes.

QuickMsgScreen 1988 -> 1738 lines; MessageHistory.h 324. Both solo boards
build. Single-TU fragment: included by UITask.cpp before QuickMsgScreen.h.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 22:41:01 +02:00
MarekZegare4
4d795ade51 feat(companion): track-back — retrace the recorded trail to its start
Add Tools › Trail → Hold Enter → Track back (shown when the trail has ≥2
points). It reuses NavView but, instead of a single fixed target, snaps onto
the route at the nearest recorded breadcrumb and walks the points in reverse,
auto-advancing the target as each is reached (within 20 m). The header shows
points remaining ("Back: 12 pt" → "Trail start"); reaching the start toasts
"Back at start" and exits. Cancel leaves at any time.

Lives in WaypointsView as a new sub-mode; advancement runs in poll()
(forwarded from TrailScreen) so it tracks GPS independently of the render
cadence. An EtaTracker drives the approach/ETA line and is reset on each
breadcrumb advance to avoid a bogus closing-speed spike.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:27:54 +02:00
MarekZegare4
c60a7a7f64 feat(companion): GPS averaging for waypoint marking (Mark avg)
Add a "Mark avg" trail setting (Off/5/10/30 s). When set, Tools › Trail ›
Mark here samples the GPS fix once a second for the chosen window and stores
the mean position instead of one instantaneous fix — a steadier, more
accurate mark for a precise spot. A progress screen shows time left + sample
count; Cancel aborts; the window then opens the label keyboard as usual.
Off (default) keeps marking instant.

Sampling runs in WaypointsView::poll() (forwarded from TrailScreen::poll())
so it ticks on the main loop, independent of the slow e-ink render cadence;
int64 accumulators avoid overflow over 30 samples.

Persisted as NodePrefs::gps_avg_idx (schema 0xC0DE0016): struct field +
option table, DataStore rd/write/clamp in lockstep. sizeof unchanged (the
uint8_t fits existing tail padding) so the 2488 tripwire still holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:19:43 +02:00
MarekZegare4
35622b7693 docs(solo-ui): sync framework guide with onShow/savePrefsIfDirty/fail-safe
Three spots lagged the recent refactors:
- §8 + §10 still taught the inline `if (_dirty) { savePrefs(); _dirty=false; }`
  pattern; now point at `_task->savePrefsIfDirty(_dirty)`.
- §1 wiring contract now notes the nullptr-init / setCurrScreen null-guard
  fail-safe (steps 1/3/4 are compile-checked, only a missed `new` slips through).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:55:36 +02:00
MarekZegare4
cc88e89a64 refactor(companion): extract drawRowSelection() for the canonical list-row bar
Every drawList/AccordionList row opened with the same hand-written
display.drawSelectionRow(0, y-1, width-reserve, lineStep-1, sel) line —
14 copies of the same geometry and magic offsets. Add a drawRowSelection(d,
y, sel, reserve) helper in icons.h next to drawList and route the canonical
sites through it (Bot/LiveShare/Locator/Repeater/Settings/Tools/Nearby/
QuickMsg/Waypoints).

Rows that intentionally differ (full-width DashboardConfig/QuickMsg, the
keyboard/ringtone grids) keep their explicit drawSelectionRow() call — the
helper is opt-in rather than baked into drawList, so legitimate variants
aren't forced into one geometry. Framework doc updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:53:18 +02:00
MarekZegare4
0e6bd743a6 refactor(companion): unify screen save-on-exit via UITask::savePrefsIfDirty()
The "_dirty bool, then if (_dirty) the_mesh.savePrefs() on exit" pattern was
duplicated across 9 screens with subtle divergence: some cleared the flag
after saving, some left it set and relied on onShow() to reset. Replace all
12 exit sites with a single _task->savePrefsIfDirty(flag) helper that saves
once only if dirty and always clears the flag, so the "did we touch flash?"
answer lives in one place and the reset is consistent.

Edit sites still mark the flag manually (inherent to change tracking); only
the persist-on-exit boilerplate is centralised.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:47:51 +02:00
MarekZegare4
15716d2b03 fix(companion): nullptr-init screen pointers so a forgotten new() fails safe
Adding a screen touches 4 sites; 3 (member decl, gotoX decl, gotoX def) are
compile-checked, but a missed `new XScreen()` in begin() left the pointer
uninitialised and crashed at first navigation. Give every screen member an
in-class nullptr initialiser and bail early in setCurrScreen(nullptr) so the
mistake is an inert no-op instead of a null deref. Document the 4-site
registration contract on the member block.

A full registry table was considered and rejected: the named gotoXScreen()
methods are a depended-upon API (~30 call sites, menu dispatch + back-nav),
so a table would add an enum + indirection without removing them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:42:59 +02:00
MarekZegare4
eedd47d1e1 refactor(companion): hoist screen entry into virtual UIScreen::onShow()
Replace the ad-hoc enter()/markClean() entry methods (which lived outside
the UIScreen interface and were invoked via casts from each gotoX) with a
virtual onShow() lifecycle hook called centrally by setCurrScreen().

This removes the "forgot to call enter() in a new navigator" footgun and
the unchecked cast smell: 12 navigators collapse to one-line
setCurrScreen(x) calls, and override enforces signature match. Two entries
that carry a parameter/variant keep an explicit typed call after
setCurrScreen(): RingtoneEditor::selectSlot(slot) and TrailScreen::showMapView().

Behaviour-preserving: only screens that previously had enter() get an
onShow() override; Splash/Home/QuickMsg/Diag keep no reset as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:37:04 +02:00
MarekZegare4
bcca97a848 docs(companion): document the screen-fragment single-TU contract
The ui-new/*.h screens are header fragments compiled only as part of
UITask.cpp, in include order. Two implicit rules a contributor can trip on:
include order (a static inline helper / shared scratch is visible only to
later fragments) and single-TU-only (some fragments define external-linkage
symbols at file scope, e.g. NearbyScreen::FILTER_LABELS, so reusing one from a
second .cpp is a duplicate-symbol link error).

Spell both out in a contract comment at the inclusion block, and raise the
single-TU note to a callout in the framework guide. Comments only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:20:47 +02:00
MarekZegare4
b624d03e96 docs(companion): make the entity-reference cleanup contract explicit
The onContactRemoved/onChannelRemoved hooks already clear every NodePrefs
field keyed on a contact pubkey or channel index (verified complete), but the
convention was opt-in with no reminder — new per-entity state could silently
forget to register, the class of bug fixed earlier this session.

Add a CONTRACT comment above each hook listing what it covers and instructing
additions to land there, plus a terse [del->onContactRemoved] /
[del->onChannelRemoved] tag on each of the eight keyed fields in NodePrefs.h.
Now editing either side (struct field or hook) points at the other. Comments
only; no behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:17:38 +02:00
MarekZegare4
325b200d07 fix(companion): compile-time tripwire for NodePrefs serialization drift
NodePrefs is written/read field-by-field in DataStore::savePrefs/loadPrefsInt;
the on-disk format is the struct's field layout, with nothing checking that the
two hand-written sequences still match the struct. A forgotten read/write
silently misaligns every later field — the highest-blast-radius convention in
the codebase.

Add a static_assert on sizeof(NodePrefs): changing a data member trips it with
a checklist (sync save/load, clamp on load, bump SCHEMA_SENTINEL, update the
size). A manual checkpoint, not a proof, but it turns silent drift into a build
break. Size is variant-stable (all arrays sized by NodePrefs' own constants),
verified on both nRF52 boards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:13:05 +02:00
MarekZegare4
089048a036 refactor(ui): align screens to the documented UI conventions
Audit pass against docs/design/solo_ui_framework.md, converging deviations:

- prev/next: replace hand-rolled (c == KEY_LEFT || c == KEY_PREV) with
  keyIsPrev()/keyIsNext() in QuickMsgScreen, RingtoneEditor, TrailScreen,
  FullscreenMsgView. Two were real rotary-encoder gaps, not just style:
  NearbyScreen's filter and WaypointsView's hemisphere toggle used raw
  KEY_LEFT/RIGHT with no encoder twin, so the encoder couldn't drive them.
- NearbyScreen's list right column used a manual setCursor(width -
  getTextWidth(...)) instead of the existing drawTextRightAlign() helper
  (which also UTF-8-translates) — now uses it, like Diagnostics/Repeater.
- RepeaterScreen hand-rolled the whole drawList skeleton (scroll clamp +
  reserve + row loop + indicator); collapsed onto drawList().

No D-pad behaviour change; more uniform encoder support. Net -19 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:01:29 +02:00
MarekZegare4
e44df01b9b docs: add a solo UI framework developer guide
Document the reusable building blocks the solo UI has grown — the UIScreen
model and screen wiring, DisplayDriver layout metrics, drawList/AccordionList,
the popup/keyboard/digit-editor/fullscreen/nav components, the geo + state-store
+ persistence helpers, mini-icons, input conventions, and the cross-cutting
gotchas (single-thread render, e-ink pacing, reference cleanup). Ends with a
worked "add a new Tools screen" example.

No code change; the helper layer is already well-factored (the recent dedup
passes closed the remaining gaps), so this captures it for contributors rather
than extracting more. Linked from the README docs table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 17:51:15 +02:00
MarekZegare4
fe19d1b0a7 refactor(companion): dedup age formatting + reply-prefix parsing; drop dead code
Consolidation pass over the message/nearby UI, no functional change beyond
one intentional display tweak:

- Age tags: NearbyScreen::fmtAge, the nearby list's inline column, and
  QuickMsgScreen::fmtMsgAge each reimplemented the same s/m/h bucket ladder
  on top of geo::fmtAgeShort. All now delegate to it (fmtMsgAge removed).
  Visible effect: ages over 24h render as "Nd" instead of capped hours,
  matching the Locator target picker which already used fmtAgeShort.
- Reply prefix: the "@[nick] " parse was duplicated in skipReplyPrefix() and
  FullscreenMsgView::render(). Extracted to one msgReplyBody() helper (body,
  plus optional addressee nick) — one place to handle its edge cases.
- LiveTrack: the expiry predicate was duplicated in expire()/isActive();
  extracted to a private expired() helper.
- Removed a dead M_PI define (and unused <math.h>) in NearbyScreen.h, and a
  comment pointing at a CODE_REVIEW.md that doesn't exist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 17:43:17 +02:00