diff --git a/docs/solo/tools.md b/docs/solo/tools.md index d4fc8150..e4b533d8 100644 --- a/docs/solo/tools.md +++ b/docs/solo/tools.md @@ -277,6 +277,11 @@ Right** between them, **Up / Down** scrolls): battery; anything that needs your position (the trail, live share, the locator) keeps it on. +With the GPS switched off, starting the trail, live share or track back, or +sharing or marking your position, asks first (**GPS is off**): turning it on +there starts what you chose; a share or a mark waits for the first fix, as +the last one may be old. SOS doesn't ask: it turns the GPS on itself. + > [!NOTE] > **Wio Tracker L2:** the Diagnostics app, with a **Noise** tab that > measures the noise floor over time and history charts you can touch for the diff --git a/examples/companion_radio/GeoUtils.h b/examples/companion_radio/GeoUtils.h index 503c2c81..50b86fa1 100644 --- a/examples/companion_radio/GeoUtils.h +++ b/examples/companion_radio/GeoUtils.h @@ -45,6 +45,18 @@ static inline const char* bearingCardinal(int deg) { return dirs[((deg + 22) % 360) / 45]; } +// How far to turn from course `cog` to bearing `to` (degrees): -180..179, right +// positive. Within AHEAD_DEG either way the target counts as straight on. +static const int AHEAD_DEG = 12; +static inline int turnDeg(int to, int cog) { return (((to - cog) % 360 + 540) % 360) - 180; } +static inline bool isAhead(int turn) { return turn <= AHEAD_DEG && turn >= -AHEAD_DEG; } + +// "50.06143,19.93658": a coordinate as messages carry it, and parseLatLon reads +// back. `spaced` ("50.06143, 19.93658") to show it, or to edit it. +static inline void fmtLatLon(char* buf, int n, int32_t lat_1e6, int32_t lon_1e6, bool spaced = false) { + snprintf(buf, n, spaced ? "%.5f, %.5f" : "%.5f,%.5f", lat_1e6 / 1e6, lon_1e6 / 1e6); +} + // Human distance string. Metric: "850m" / "2.3km" / "140km". Imperial: // "850ft" / "2.3mi" / "140mi" (feet below ~1000 ft, then miles). `spaced` // puts a space before the unit ("850 m"), for value rows with room for it. @@ -143,6 +155,14 @@ static inline bool parseLatLon(const char* text, int32_t& lat_1e6, int32_t& lon_ // the tag (it just looks like a coordinate). #define LOCATION_MSG_TAG "[LOC]" +// "[LOC]lat,lon", then `name` when given: a DM has no sender in front of it. +static inline void fmtLocShare(char* buf, int n, int32_t lat_1e6, int32_t lon_1e6, const char* name = nullptr) { + int o = snprintf(buf, n, LOCATION_MSG_TAG); + if (o < 0 || o >= n) return; + fmtLatLon(buf + o, n - o, lat_1e6, lon_1e6); + if (name && name[0]) { size_t l = strlen(buf); snprintf(buf + l, n - l, " %s", name); } +} + // An SOS with a position ("SOS 50.06143,19.93658 ...", "SOS last ...", see // ui-core/SosEngine.h): `text` without the sender's name in front. Only at the // very start, so a message that mentions SOS isn't taken for one. diff --git a/examples/companion_radio/ui-core/LiveShareEngine.h b/examples/companion_radio/ui-core/LiveShareEngine.h index 48f175de..b356111c 100644 --- a/examples/companion_radio/ui-core/LiveShareEngine.h +++ b/examples/companion_radio/ui-core/LiveShareEngine.h @@ -73,6 +73,12 @@ public: // Start the session afresh (next tick treats it as a new enable: re-announce // + new duration clock). void restartSession() { _was_enabled = false; } + // Sharing on (a session of its own, announced at once) or off. + void setEnabled(bool on) { + if (!_prefs) return; + _prefs->loc_share_enabled = on ? 1 : 0; + if (on) restartSession(); + } // Restart only the session's duration clock -- unlike restartSession(), no // re-announce. For a changed "Stop after" length, where position hasn't // changed. No-op before a session has started (loop() sets the clock then). @@ -108,7 +114,7 @@ public: if (_prefs->loc_share_target_type == 0) { // Channel: sendGroupMessage prepends ": ", so the payload already // names the sender — keep the [LOC] text bare. - snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f", lat / 1e6, lon / 1e6); + geo::fmtLocShare(text, sizeof(text), lat, lon); ChannelDetails ch; if (!the_mesh.getChannel(_prefs->loc_share_channel_idx, ch)) return false; return the_mesh.sendGroupMessage(rtc_clock.getCurrentTime(), ch.channel, @@ -120,8 +126,7 @@ public: ContactInfo* c = the_mesh.lookupContactByPubKey(_prefs->loc_share_dm_prefix, NodePrefs::FAVOURITE_PREFIX_LEN); if (!c) return false; - snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f %s", - lat / 1e6, lon / 1e6, the_mesh.getNodeName()); + geo::fmtLocShare(text, sizeof(text), lat, lon, the_mesh.getNodeName()); uint32_t expected_ack = 0, est_timeout = 0; return the_mesh.sendMessage(*c, rtc_clock.getCurrentTime(), 0, text, expected_ack, est_timeout) > 0; } diff --git a/examples/companion_radio/ui-core/WaypointModel.h b/examples/companion_radio/ui-core/WaypointModel.h index 8ddac259..39fb84c3 100644 --- a/examples/companion_radio/ui-core/WaypointModel.h +++ b/examples/companion_radio/ui-core/WaypointModel.h @@ -76,9 +76,9 @@ public: void shareText(int i, char* out, size_t n) const { if (i < 0 || i >= _store.count()) { out[0] = '\0'; return; } const Waypoint& w = _store.at(i); - double lat = w.lat_1e6 / 1000000.0, lon = w.lon_1e6 / 1000000.0; - if (w.label[0]) snprintf(out, n, WAYPOINT_MSG_TAG "%.5f,%.5f %s", lat, lon, w.label); - else snprintf(out, n, WAYPOINT_MSG_TAG "%.5f,%.5f", lat, lon); + int o = snprintf(out, n, WAYPOINT_MSG_TAG); + geo::fmtLatLon(out + o, (int)n - o, w.lat_1e6, w.lon_1e6); + if (w.label[0]) { size_t l = strlen(out); snprintf(out + l, n - l, " %s", w.label); } } private: diff --git a/examples/companion_radio/ui-lvgl/AdminScreen.h b/examples/companion_radio/ui-lvgl/AdminScreen.h index b089dbfc..f7b38e2b 100644 --- a/examples/companion_radio/ui-lvgl/AdminScreen.h +++ b/examples/companion_radio/ui-lvgl/AdminScreen.h @@ -309,10 +309,7 @@ void UITask::adminValuePopup() { if (f->kind == admin::K_ONOFF) { lv_obj_t* r = centerRow(panel); label(r, "Off", THEME_FONT_BODY, theme::TEXT_MUTED); - lv_obj_t* sw = lv_switch_create(r); - lv_obj_set_size(sw, 56, 28); - if (S.value != 0) lv_obj_add_state(sw, LV_STATE_CHECKED); - lv_obj_add_event_cb(sw, onAdminValue, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)V_TOGGLE); + switchCreate(r, S.value != 0, onAdminValue, (void*)(uintptr_t)V_TOGGLE); label(r, "On", THEME_FONT_BODY, theme::TEXT_MUTED); } else if (f->kind == admin::K_NUMBER) { lv_obj_t* r = centerRow(panel); diff --git a/examples/companion_radio/ui-lvgl/BotScreen.h b/examples/companion_radio/ui-lvgl/BotScreen.h index 02094ded..e34962fa 100644 --- a/examples/companion_radio/ui-lvgl/BotScreen.h +++ b/examples/companion_radio/ui-lvgl/BotScreen.h @@ -58,12 +58,6 @@ static void botChip(const char* ph, void* parent) { lv_obj_add_event_cb(b, onBotPlaceholder, LV_EVENT_CLICKED, NULL); lv_obj_center(label(b, ph, THEME_FONT_SMALL, theme::TEXT)); } -// A switchRow() for something that isn't a plain pref: `cb` gets `idx`. -static void switchRowIdx(lv_obj_t* parent, const char* text, const char* hint, bool on, lv_event_cb_t cb, int idx) { - lv_obj_t* sw = switchRow(parent, text, hint, nullptr); - if (on) lv_obj_add_state(sw, LV_STATE_CHECKED); - lv_obj_add_event_cb(sw, cb, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)idx); -} static void onOpenBot(lv_event_t* e) { (void)e; s_ui->showBot(); } static lv_obj_t* botHourButton(lv_obj_t* parent, const char* text, uint8_t act, bool accent) { @@ -148,7 +142,7 @@ void UITask::buildBot() { const botcfg::Row& r = botcfg::row(s_tab, i); uint8_t* fl = botcfg::flag(p, r.kind); if (fl && !botcfg::isScope(r.kind)) { - switchRowIdx(g, r.label, r.hint, *fl, onBotSwitch, i); + switchRow(g, r.label, r.hint, *fl, onBotSwitch, (void*)(uintptr_t)i); continue; } uint16_t* cmd_off = botcfg::cmdOff(p, r.kind); @@ -255,7 +249,7 @@ void UITask::botRow(int row) { if (uint16_t* off = botcfg::cmdOff(p, r.kind)) { lv_obj_t* g = group(scrollList(navPopupPanel("Allowed commands", true)), nullptr); for (int i = 0; i < botcmd::count(botcfg::actionsOn(p, r.kind)); i++) - switchRowIdx(g, botcmd::CMDS[i].label, botcmd::CMDS[i].hint, !(*off & botcmd::flag(i)), onBotCmd, i); + switchRow(g, botcmd::CMDS[i].label, botcmd::CMDS[i].hint, !(*off & botcmd::flag(i)), onBotCmd, (void*)(uintptr_t)i); return; } diff --git a/examples/companion_radio/ui-lvgl/ChannelScreen.h b/examples/companion_radio/ui-lvgl/ChannelScreen.h index 2967199d..2c7c172d 100644 --- a/examples/companion_radio/ui-lvgl/ChannelScreen.h +++ b/examples/companion_radio/ui-lvgl/ChannelScreen.h @@ -186,19 +186,9 @@ void UITask::buildChannelEdit() { s_secret = chanField(body, "Passphrase"); lv_textarea_set_max_length(s_secret, 32); - lv_obj_t* row = settingRow(body, "Hex key", "32 characters"); - s_hex_sw = lv_switch_create(row); - lv_obj_set_size(s_hex_sw, 46, 24); - lv_obj_align(s_hex_sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); - lv_obj_add_event_cb(s_hex_sw, onChanHex, LV_EVENT_VALUE_CHANGED, NULL); + s_hex_sw = switchRow(body, "Hex key", "32 characters", false, onChanHex); - lv_obj_t* save = lv_button_create(body); - lv_obj_set_size(save, LV_PCT(100), 38); - lv_obj_set_style_shadow_width(save, 0, 0); - lv_obj_set_style_radius(save, theme::RADIUS, 0); - lv_obj_add_event_cb(save, onChanSave, LV_EVENT_CLICKED, NULL); - lv_obj_center(label(save, LV_SYMBOL_OK " Save", THEME_FONT_BODY, theme::TEXT)); - stylePrimary(save); + primaryButton(body, LV_SYMBOL_OK " Save", onChanSave); s_status = noteLabel(body, "", THEME_FONT_SMALL, theme::FAIL); if (edit) { diff --git a/examples/companion_radio/ui-lvgl/ClockScreen.h b/examples/companion_radio/ui-lvgl/ClockScreen.h index 21a08274..7e41af2e 100644 --- a/examples/companion_radio/ui-lvgl/ClockScreen.h +++ b/examples/companion_radio/ui-lvgl/ClockScreen.h @@ -314,10 +314,8 @@ void UITask::buildClock() { 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); + lv_obj_t* sw = s_alarm_sw = switchCreate(on, _prefs->alarm_on, nullptr, nullptr); 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]; diff --git a/examples/companion_radio/ui-lvgl/DiagScreen.h b/examples/companion_radio/ui-lvgl/DiagScreen.h index 02b492cd..8080c1dc 100644 --- a/examples/companion_radio/ui-lvgl/DiagScreen.h +++ b/examples/companion_radio/ui-lvgl/DiagScreen.h @@ -366,14 +366,8 @@ void UITask::buildDiag() { if (s_tab == TAB_NOISE) { noteLabel(s_list, "What the radio hears with nothing on air, on the mesh frequency and 1.1 MHz " "either side. Takes about 12 seconds."); - lv_obj_t* b = lv_button_create(s_list); - lv_obj_set_size(b, LV_PCT(100), 40); - lv_obj_set_style_shadow_width(b, 0, 0); - lv_obj_set_style_radius(b, theme::RADIUS, 0); - lv_obj_add_event_cb(b, onNoiseRun, LV_EVENT_CLICKED, NULL); - s_noise_status = label(b, s_noise_have ? LV_SYMBOL_REFRESH " Measure again" : LV_SYMBOL_PLAY " Measure", THEME_FONT_BODY, theme::TEXT); - lv_obj_center(s_noise_status); - stylePrimary(b); + lv_obj_t* b = primaryButton(s_list, s_noise_have ? LV_SYMBOL_REFRESH " Measure again" : LV_SYMBOL_PLAY " Measure", onNoiseRun); + s_noise_status = lv_obj_get_child(b, 0); if (s_noise_have) { float mesh_f = _prefs ? _prefs->freq : 0; lv_obj_t* card = infoCard(s_list); diff --git a/examples/companion_radio/ui-lvgl/GpsScreen.h b/examples/companion_radio/ui-lvgl/GpsScreen.h index 8b38e315..de53ae20 100644 --- a/examples/companion_radio/ui-lvgl/GpsScreen.h +++ b/examples/companion_radio/ui-lvgl/GpsScreen.h @@ -99,6 +99,49 @@ static void onOpenGps(lv_event_t* e) { (void)e; s_ui->showGps(false); } static void onOpenGpsFromSettings(lv_event_t* e) { (void)e; s_ui->showGps(true); } static void onGpsTurnOn(lv_event_t* e) { (void)e; s_ui->setGps(true); s_ui->refreshGps(); } +// ── "GPS is off" ────────────────────────────────────────────────────────────── +// Something that needs the GPS while it's off asks first, as on the L1: Turn +// on does it, the close button leaves it undone. + +static void onGpsAsk(lv_event_t* e) { + UITask::GpsAsk what = (UITask::GpsAsk)(uintptr_t)lv_event_get_user_data(e); + s_ui->navClosePopup(); + s_ui->setGps(true); + s_ui->gpsAskDone(what); +} + +bool UITask::gpsAsked(GpsAsk what) { + if (!_core->gpsAvailable() || _core->gpsEnabled()) return false; + static const char* const NEEDS[] = { + "The trail is recorded from the GPS.", "Live share sends the GPS position.", "Track back follows the GPS.", + "The map finds you by the GPS.", "This needs a GPS fix.", + }; + lv_obj_t* panel = navPopupPanel("GPS is off", false); + noteLabel(panel, NEEDS[what]); + primaryButton(panel, LV_SYMBOL_GPS " Turn on GPS", onGpsAsk, (void*)(uintptr_t)what); + return true; +} + +// Just turned on: what asked for it. A position needs a fresh fix first (its +// last one may be old), so a share or a mark is left for then. +void UITask::gpsAskDone(GpsAsk what) { + switch (what) { + case GA_TRAIL: + _core->trail.setActive(true); + showToast("GPS on, recording the trail"); + break; + case GA_SHARE: + _core->live_share.setEnabled(true); + prefsSave(); + showToast("GPS on, sharing your position"); + break; + case GA_TRACKBACK: navToolAction(navmap::TL_TRACKBACK); break; + case GA_CENTRE: mapCenterOnMe(); break; + case GA_FIX: showToast("GPS on, waiting for a fix"); break; + } + refreshStatusBar(); +} + // Sky plot: horizon ring, 30 / 60 degree rings, N-S / E-W lines, one dot per // satellite at its azimuth (N up) and elevation (overhead in the centre). static void onGpsSkyDraw(lv_event_t* e) { @@ -480,7 +523,7 @@ void UITask::refreshGps() { if (g->utc_valid) snprintf(utc, sizeof(utc), "%02u:%02u:%02u", g->utc_h, g->utc_m, g->utc_s); int32_t lat, lon; if (fix && _core->course.currentLocation(lat, lon)) { - snprintf(pos, sizeof(pos), "%.5f, %.5f", lat / 1e6, lon / 1e6); + geo::fmtLatLon(pos, sizeof(pos), lat, lon, true); locator(lat / 1e6, lon / 1e6, grid, sizeof(grid)); } if (fix && g->alt_valid) snprintf(alt, sizeof(alt), "%.0f m", g->alt_m); diff --git a/examples/companion_radio/ui-lvgl/MapAreas.h b/examples/companion_radio/ui-lvgl/MapAreas.h index 1f8035e1..5bc98ea7 100644 --- a/examples/companion_radio/ui-lvgl/MapAreas.h +++ b/examples/companion_radio/ui-lvgl/MapAreas.h @@ -388,13 +388,7 @@ void UITask::mapAreasPopup() { } else { noteLabel(list, "No areas yet. Maps downloaded before this list are still on the card."); } - lv_obj_t* add = lv_button_create(list); - lv_obj_set_size(add, LV_PCT(100), 38); - lv_obj_set_style_radius(add, theme::RADIUS, 0); - lv_obj_set_style_shadow_width(add, 0, 0); - lv_obj_add_event_cb(add, onNewArea, LV_EVENT_CLICKED, NULL); - lv_obj_center(label(add, LV_SYMBOL_PLUS " New area", THEME_FONT_BODY, theme::TEXT)); - stylePrimary(add); + primaryButton(list, LV_SYMBOL_PLUS " New area", onNewArea); } void UITask::mapAreaPopup(int idx) { diff --git a/examples/companion_radio/ui-lvgl/MapScreen.h b/examples/companion_radio/ui-lvgl/MapScreen.h index 29a24264..ce559c65 100644 --- a/examples/companion_radio/ui-lvgl/MapScreen.h +++ b/examples/companion_radio/ui-lvgl/MapScreen.h @@ -1292,7 +1292,8 @@ void UITask::mapZoom(int delta) { void UITask::mapCenterOnMe() { int32_t lat, lon; - if (!ensureGps() || !_core->course.currentLocation(lat, lon)) { _map_follow = true; followShown(_map_center_btn, true); return; } // centres once a fix comes + if (gpsAsked(GA_CENTRE)) return; + if (!ensureGps(GA_CENTRE) || !_core->course.currentLocation(lat, lon)) { _map_follow = true; followShown(_map_center_btn, true); return; } // centres once a fix comes _map_follow = true; _map_cx = mapview::lonToTileX(lon / 1e6, _map_z); _map_cy = mapview::latToTileY(lat / 1e6, _map_z) + mapCenterBias(); diff --git a/examples/companion_radio/ui-lvgl/NavMap.h b/examples/companion_radio/ui-lvgl/NavMap.h index fcc2c5c3..f2b852fb 100644 --- a/examples/companion_radio/ui-lvgl/NavMap.h +++ b/examples/companion_radio/ui-lvgl/NavMap.h @@ -100,9 +100,8 @@ static void onTrailDraw(lv_event_t* e) { static lv_obj_t* s_dial = nullptr; static int32_t s_dial_ang = 0; // shown: degrees clockwise from the dial's top static bool s_dial_course = false; // a course is known: the top is ahead, else north -static bool s_dial_ahead = false; // within AHEAD_DEG of straight on +static bool s_dial_ahead = false; // within geo::AHEAD_DEG of straight on static const int DIAL = 38; -static const int AHEAD_DEG = 12; static const uint32_t DIAL_MS = 300; static void setDialAng(void* o, int32_t v) { @@ -159,7 +158,7 @@ static void setDial(int deg, bool course) { deg = ((deg % 360) + 360) % 360; const bool snap = course != s_dial_course || lv_obj_has_flag(s_dial, LV_OBJ_FLAG_HIDDEN); s_dial_course = course; - s_dial_ahead = course && (deg <= AHEAD_DEG || deg >= 360 - AHEAD_DEG); + s_dial_ahead = course && geo::isAhead(geo::turnDeg(deg, 0)); lv_obj_remove_flag(s_dial, LV_OBJ_FLAG_HIDDEN); lv_obj_set_style_border_color(s_dial, lv_color_hex(s_dial_ahead ? theme::OK : theme::SURFACE_2), 0); lv_anim_delete(s_dial, setDialAng); @@ -517,7 +516,7 @@ void UITask::refreshNavBar() { navmap::setDial(course ? to - cog : to, course); // Walking: how far to turn (the dial shows it); standing: where the target lies. if (course) { - int turn = ((to - cog + 540) % 360) - 180; // -180..179, right is positive + int turn = geo::turnDeg(to, cog); if (navmap::s_dial_ahead) snprintf(what, sizeof(what), "straight ahead"); else snprintf(what, sizeof(what), "turn %s %d\xC2\xB0", turn > 0 ? "right" : "left", turn > 0 ? turn : -turn); } else { @@ -732,7 +731,7 @@ static void navRowRight(lv_obj_t* row, const char* text, uint32_t col, int right void UITask::placeCard(lv_obj_t* parent, int32_t lat, int32_t lon) { lv_obj_t* card = infoCard(parent); char t[40]; - snprintf(t, sizeof(t), "%.5f, %.5f", lat / 1e6, lon / 1e6); + geo::fmtLatLon(t, sizeof(t), lat, lon, true); infoRow(card, "Coordinates", t); int32_t mlat, mlon; if (!_core->course.currentLocation(mlat, mlon)) return; @@ -831,7 +830,7 @@ void UITask::navAddWaypoint(int32_t lat, int32_t lon) { void UITask::navMarkHere() { int32_t lat, lon; - if (!ensureGps() || !_core->course.currentLocation(lat, lon)) return; + if (!ensureGps(GA_FIX) || !_core->course.currentLocation(lat, lon)) return; uint16_t secs = NodePrefs::gpsAvgSecs(_prefs ? _prefs->gps_avg_idx : 0); if (secs == 0) { navAddWaypoint(lat, lon); return; } if (_core->waypoints.full()) { showToast(waypointsFull()); return; } @@ -1030,14 +1029,10 @@ void UITask::navToolsPopup(bool back) { // What the map shows, right under the trail: used more than what follows. lv_obj_t* g = group(list, "LAYERS"); choiceRow(g, "Map", "Topo, light, dark or satellite", mapview::LAYER_OPTS, mapview::s_layer, onMapLayer); - lv_obj_t* tsw = switchRow(g, "Hiking trails", "Marked routes in their colours", nullptr); - if (lvport::trailsOn()) lv_obj_add_state(tsw, LV_STATE_CHECKED); - lv_obj_add_event_cb(tsw, onTrails, LV_EVENT_VALUE_CHANGED, NULL); + switchRow(g, "Hiking trails", "Marked routes in their colours", lvport::trailsOn(), onTrails); schemaRow(g, SETTING(flow_show)); #ifdef MAP_VECTOR // unfinished (LvglPort.h vectorOn) - lv_obj_t* vsw = switchRow(g, "Vector map (test)", "Drawn on the device: trails, contours", nullptr); - if (lvport::vectorOn()) lv_obj_add_state(vsw, LV_STATE_CHECKED); - lv_obj_add_event_cb(vsw, onVectorMap, LV_EVENT_VALUE_CHANGED, NULL); + switchRow(g, "Vector map (test)", "Drawn on the device: trails, contours", lvport::vectorOn(), onVectorMap); char vr[48]; int nv = mapview::s_vpacks.count(); snprintf(vr, sizeof(vr), nv ? "%d on the card" : "None - packs go in /vmap", nv); @@ -1094,9 +1089,7 @@ void UITask::navToolsPopup(bool back) { int na = mapview::s_areas.count(); snprintf(ar, sizeof(ar), na ? "%d on the card - new, rename, refresh, delete" : "None yet - pick one with a frame", na); listRow(g, "Map areas", ar, onNavTool, (void*)(uintptr_t)navmap::TL_AREAS); - lv_obj_t* lsw = switchRow(g, "Live tiles", "Load missing tiles over WiFi", nullptr); - if (lvport::liveTiles()) lv_obj_add_state(lsw, LV_STATE_CHECKED); - lv_obj_add_event_cb(lsw, onLiveTiles, LV_EVENT_VALUE_CHANGED, NULL); + switchRow(g, "Live tiles", "Load missing tiles over WiFi", lvport::liveTiles(), onLiveTiles); refreshNavTools(); @@ -1360,10 +1353,11 @@ void UITask::navToolAction(uint8_t act) { switch (act) { case navmap::TL_TRAIL_TOGGLE: if (!tr.isActive()) { + if (gpsAsked(GA_TRAIL)) return; // its popup in place of this one tr.setActive(true); int32_t lat, lon; if (_core->course.currentLocation(lat, lon)) showToast("Recording the trail"); - else ensureGps(); // turns GPS on, or says it's waiting + else ensureGps(GA_TRAIL); // says it's waiting } else { tr.setActive(false); showToast("Trail stopped"); @@ -1402,11 +1396,12 @@ void UITask::navToolAction(uint8_t act) { break; case navmap::TL_TRACKBACK: { if (navmap::s_tb.active()) { navmap::s_tb.stop(); showToast("Track back stopped"); break; } + if (gpsAsked(GA_TRACKBACK)) return; // its popup in place of this one int32_t lat = 0, lon = 0; bool fix = _core->course.currentLocation(lat, lon); if (!navmap::s_tb.start(tr.store(), fix, lat, lon)) { showToast("No trail to walk back"); break; } navmap::s_eta.reset(); - if (!fix) ensureGps(); + if (!fix) ensureGps(GA_TRACKBACK); // says it's waiting navClosePopup(); navFrameTarget(); refreshNavBar(); @@ -1421,24 +1416,24 @@ void UITask::navToolAction(uint8_t act) { } case navmap::TL_SHARE_TOGGLE: if (_prefs->loc_share_enabled) { - _prefs->loc_share_enabled = 0; + _core->live_share.setEnabled(false); showToast("Live share stopped"); } else { - _prefs->loc_share_enabled = 1; - _core->live_share.restartSession(); + if (gpsAsked(GA_SHARE)) return; + _core->live_share.setEnabled(true); int32_t lat, lon; if (_core->course.currentLocation(lat, lon)) showToast("Sharing your position"); - else ensureGps(); + else ensureGps(GA_SHARE); // says it's waiting } prefsSave(); break; case navmap::TL_SHARE_ONCE: { int32_t lat, lon; - if (!ensureGps() || !_core->course.currentLocation(lat, lon)) break; + if (!ensureGps(GA_FIX) || !_core->course.currentLocation(lat, lon)) break; // With a session on, straight to its target; otherwise pick a conversation. if (_prefs->loc_share_enabled && _core->live_share.send(lat, lon)) { showToast("Position sent"); break; } char text[40]; - snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f", lat / 1e6, lon / 1e6); + geo::fmtLocShare(text, sizeof(text), lat, lon); navClosePopup(); shareToMessage(text); return; @@ -1520,7 +1515,7 @@ void UITask::navTargetsPopup() { const Waypoint& w = wp.at(i); char title[WAYPOINT_LABEL_LEN + 8], sub[40], d[12]; snprintf(title, sizeof(title), UI_SYMBOL_FLAG " %s", w.label[0] ? w.label : "(unnamed)"); - snprintf(sub, sizeof(sub), "%.5f, %.5f", w.lat_1e6 / 1e6, w.lon_1e6 / 1e6); + geo::fmtLatLon(sub, sizeof(sub), w.lat_1e6, w.lon_1e6, true); lv_obj_t* row = listRow(list, title, sub, onNavPick, (void*)(uintptr_t)navmap::code(navmap::T_WAYPOINT, i)); lv_obj_t* more = lv_button_create(row); // the waypoint menu lv_obj_set_size(more, 40, 34); diff --git a/examples/companion_radio/ui-lvgl/NodesView.h b/examples/companion_radio/ui-lvgl/NodesView.h index f890e317..02d95dc2 100644 --- a/examples/companion_radio/ui-lvgl/NodesView.h +++ b/examples/companion_radio/ui-lvgl/NodesView.h @@ -294,8 +294,6 @@ static void nodeFavText() { if (s_nd_fav) lv_label_set_text(s_nd_fav, s_node.fav ? "Remove from favourites" : "Add to favourites"); // the star in its icon column } -static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref); // below - // One of the node's "Can ask for" switches; user data = its TELEM_PERM_* bit. static void onNodeTelemSwitch(lv_event_t* e) { ContactInfo* ci = the_mesh.lookupContactByPubKey(s_node.pub_key, PUB_KEY_SIZE); @@ -366,9 +364,7 @@ void UITask::buildNode() { }; uint8_t perms = contactctl::telemetry(*ci); for (const auto& t : T) { - lv_obj_t* sw = switchRow(g, t.text, t.sub, nullptr); - if (perms & t.bit) lv_obj_add_state(sw, LV_STATE_CHECKED); - lv_obj_add_event_cb(sw, onNodeTelemSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)t.bit); + switchRow(g, t.text, t.sub, perms & t.bit, onNodeTelemSwitch, (void*)(uintptr_t)t.bit); } } @@ -408,7 +404,7 @@ void UITask::refreshNode() { bearing = az; snprintf(dist, sizeof(dist), "%s %d\xC2\xB0 %s", d, az, geo::bearingCardinal(az)); } - snprintf(pos, sizeof(pos), "%.5f, %.5f", e.lat_e6 / 1e6, e.lon_e6 / 1e6); + geo::fmtLatLon(pos, sizeof(pos), e.lat_e6, e.lon_e6, true); } else if (!_node_from_scan) { snprintf(pos, sizeof(pos), "Not shared"); } diff --git a/examples/companion_radio/ui-lvgl/OtaScreen.h b/examples/companion_radio/ui-lvgl/OtaScreen.h index 50baf9a0..f48f2ef9 100644 --- a/examples/companion_radio/ui-lvgl/OtaScreen.h +++ b/examples/companion_radio/ui-lvgl/OtaScreen.h @@ -198,15 +198,8 @@ void UITask::buildOta() { lv_bar_set_range(_ota_bar, 0, 1000); lv_obj_set_style_bg_color(_ota_bar, lv_color_hex(theme::SURFACE), LV_PART_MAIN); lv_obj_set_style_bg_color(_ota_bar, lv_color_hex(theme::ACCENT), LV_PART_INDICATOR); - lv_obj_t* b = lv_button_create(body); - lv_obj_set_size(b, LV_PCT(100), 40); - lv_obj_set_style_shadow_width(b, 0, 0); - lv_obj_set_style_radius(b, theme::RADIUS, 0); - lv_obj_add_event_cb(b, onOtaButton, LV_EVENT_CLICKED, NULL); - _ota_btn = b; - _ota_btn_lbl = label(b, "", THEME_FONT_BODY, theme::TEXT); - lv_obj_center(_ota_btn_lbl); - stylePrimary(b); + _ota_btn = primaryButton(body, "", onOtaButton); + _ota_btn_lbl = lv_obj_get_child(_ota_btn, 0); groupNote(body, "From github.com/" OTA_REPO " releases, over WiFi. About a minute; keep the device " "charged. Settings, contacts and messages are kept."); refreshOta(); diff --git a/examples/companion_radio/ui-lvgl/QuickPanel.h b/examples/companion_radio/ui-lvgl/QuickPanel.h index c35d3aa1..35a0c8f6 100644 --- a/examples/companion_radio/ui-lvgl/QuickPanel.h +++ b/examples/companion_radio/ui-lvgl/QuickPanel.h @@ -371,15 +371,18 @@ void UITask::quickPanelTile(int t, bool hold) { #endif break; case T_TRAIL: - _core->trail.setActive(!_core->trail.isActive()); - if (_core->trail.isActive()) ensureGps(); // turns GPS on, or says it's waiting - break; - case T_SHARE: + case T_SHARE: { if (!_prefs) break; - _prefs->loc_share_enabled = !_prefs->loc_share_enabled; - if (_prefs->loc_share_enabled) { _core->live_share.restartSession(); ensureGps(); } - prefsSave(); + const bool on = t == T_TRAIL ? _core->trail.isActive() : _prefs->loc_share_enabled; + if (!on && _core->gpsAvailable() && !_core->gpsEnabled()) { // "GPS is off", over the screen + quickPanelClose(false); + gpsAsked(t == T_TRAIL ? GA_TRAIL : GA_SHARE); + return; + } + if (t == T_TRAIL) _core->trail.setActive(!on); + else { _core->live_share.setEnabled(!on); prefsSave(); } break; + } } refreshStatusBar(); quickPanelRefresh(); diff --git a/examples/companion_radio/ui-lvgl/QuickScreen.h b/examples/companion_radio/ui-lvgl/QuickScreen.h index e9055b8b..d33a22d7 100644 --- a/examples/companion_radio/ui-lvgl/QuickScreen.h +++ b/examples/companion_radio/ui-lvgl/QuickScreen.h @@ -202,15 +202,11 @@ void UITask::advertSend(bool flood) { static void bluetoothRow(lv_obj_t* body) { char sub[48]; qview::bluetoothHint(sub, sizeof(sub)); - lv_obj_t* sw = switchRow(body, LV_SYMBOL_BLUETOOTH " Bluetooth", sub, nullptr); - if (s_ui->isBluetoothEnabled()) lv_obj_add_state(sw, LV_STATE_CHECKED); - lv_obj_add_event_cb(sw, qview::onBtSwitch, LV_EVENT_VALUE_CHANGED, NULL); + lv_obj_t* sw = switchRow(body, LV_SYMBOL_BLUETOOTH " Bluetooth", sub, s_ui->isBluetoothEnabled(), qview::onBtSwitch); #ifdef BLE_PIN_CODE - lv_obj_t* row = lv_obj_get_parent(sw); - rowPressable(row); - lv_obj_add_event_cb(row, [](lv_event_t* e) { - if (lv_event_get_target(e) == lv_event_get_current_target(e)) onBlePin(e); // not the switch - }, LV_EVENT_CLICKED, NULL); + switchRowOpens(sw, onBlePin); +#else + (void)sw; #endif } diff --git a/examples/companion_radio/ui-lvgl/RadioScreen.h b/examples/companion_radio/ui-lvgl/RadioScreen.h index 805ecfe2..03a96c0a 100644 --- a/examples/companion_radio/ui-lvgl/RadioScreen.h +++ b/examples/companion_radio/ui-lvgl/RadioScreen.h @@ -111,10 +111,9 @@ void UITask::radioGroup(int i) { int tx = p->tx_power_dbm < TX_MIN ? TX_MIN : p->tx_power_dbm > TX_MAX ? TX_MAX : p->tx_power_dbm; rowDropdown(settingRow(g, "TX power", p->tx_apc ? "Ceiling for auto power" : nullptr), s_opts, tx - TX_MIN, 0, onRadioDropdown, R_TX); - lv_obj_t* sw = switchRow(g, "Auto power", p->client_repeat ? "Off while repeating" : "Lowers power on good links", nullptr); - if (p->tx_apc) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_t* sw = switchRow(g, "Auto power", p->client_repeat ? "Off while repeating" : "Lowers power on good links", + p->tx_apc, onRadioSwitch, (void*)(uintptr_t)R_APC); if (p->client_repeat) lv_obj_add_state(sw, LV_STATE_DISABLED); - lv_obj_add_event_cb(sw, onRadioSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)R_APC); } void UITask::radioSet(int which, int v) { diff --git a/examples/companion_radio/ui-lvgl/RepeaterScreen.h b/examples/companion_radio/ui-lvgl/RepeaterScreen.h index f1e207c8..526b9a79 100644 --- a/examples/companion_radio/ui-lvgl/RepeaterScreen.h +++ b/examples/companion_radio/ui-lvgl/RepeaterScreen.h @@ -54,9 +54,7 @@ void UITask::buildRepeater() { NodePrefs* p = _prefs; lv_obj_t* g = group(body, nullptr); - lv_obj_t* sw = switchRow(g, "Repeater", p->client_repeat ? "Relaying for others" : "Relay others' messages", nullptr); - if (p->client_repeat) lv_obj_add_state(sw, LV_STATE_CHECKED); - lv_obj_add_event_cb(sw, onRptSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)RP_ON); + lv_obj_t* sw = switchRow(g, "Repeater", p->client_repeat ? "Relaying for others" : "Relay others' messages", p->client_repeat, onRptSwitch, (void*)(uintptr_t)RP_ON); s_on_sub = rowSub(lv_obj_get_parent(sw)); groupNote(body, "Uses more battery; auto power pauses."); fill(&UITask::repeaterGroup, 1, 3); // the rest as the loop goes diff --git a/examples/companion_radio/ui-lvgl/SettingsPages.h b/examples/companion_radio/ui-lvgl/SettingsPages.h index 907b012d..0d476fef 100644 --- a/examples/companion_radio/ui-lvgl/SettingsPages.h +++ b/examples/companion_radio/ui-lvgl/SettingsPages.h @@ -12,30 +12,10 @@ static void onKeyboardAlphabet(lv_event_t* e) { s_ui->setKeyboardAlphabets(choiceSelected(s_kb_main_dd), choiceSelected(s_kb_alt_dd)); } -// A preference toggle (0/1 byte in NodePrefs), saved on change. -static void onPrefSwitch(lv_event_t* e) { - uint8_t* pref = (uint8_t*)lv_event_get_user_data(e); - *pref = lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED) ? 1 : 0; - prefsSave(); -} - static void onGpsSwitch(lv_event_t* e) { s_ui->setGps(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)); } -// pref == nullptr: the caller wires the switch itself. -static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref) { - lv_obj_t* row = settingRow(parent, text, sub, 236); // the hint clear of the switch - lv_obj_t* sw = lv_switch_create(row); - lv_obj_set_size(sw, 46, 24); - lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); - if (pref) { - if (*pref) lv_obj_add_state(sw, LV_STATE_CHECKED); - lv_obj_add_event_cb(sw, onPrefSwitch, LV_EVENT_VALUE_CHANGED, pref); - } - return sw; -} - // ── Schema-driven settings (ui-core/SettingsSchema.h) ───────────────────────── // Settings > Display: ui-new's Message alert as a switch -- the banner, or @@ -44,9 +24,7 @@ static void onMsgBannerSwitch(lv_event_t* e) { s_ui->setMsgBanner(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)); } void UITask::msgBannerRow(lv_obj_t* card) { - lv_obj_t* sw = switchRow(card, "Message banner", "A new message drops in from the top", nullptr); - if (!_prefs || _prefs->msg_alert != NodePrefs::MSG_ALERT_OFF) lv_obj_add_state(sw, LV_STATE_CHECKED); - lv_obj_add_event_cb(sw, onMsgBannerSwitch, LV_EVENT_VALUE_CHANGED, NULL); + switchRow(card, "Message banner", "A new message drops in from the top", !_prefs || _prefs->msg_alert != NodePrefs::MSG_ALERT_OFF, onMsgBannerSwitch); } void UITask::setMsgBanner(bool on) { if (!_prefs) return; @@ -161,9 +139,7 @@ void UITask::buildSchemaSettings() { schemaRow(g, SETTING(display_brightness)); schemaRow(g, SETTING(auto_off_secs)); schemaRow(g, SETTING(msg_wake)); - lv_obj_t* tw = switchRow(g, "Tap to wake", "Off: only the top button wakes it", nullptr); // in NVS - if (_tap_wake) lv_obj_add_state(tw, LV_STATE_CHECKED); - lv_obj_add_event_cb(tw, onTapWake, LV_EVENT_VALUE_CHANGED, NULL); + switchRow(g, "Tap to wake", "Off: only the top button wakes it", _tap_wake, onTapWake); g = group(body, "LOCK"); schemaRow(g, SETTING(auto_lock)); listRow(g, "Screen PIN", pinSet() ? "On - asked when the screen wakes" : "Off", onPinSetup, NULL); @@ -207,7 +183,7 @@ enum : int { OWNPOS_TYPE, OWNPOS_WAYPOINTS, OWNPOS_CLEAR, OWNPOS_WP0 = 100 }; void UITask::ownPositionText(char* sub, size_t n) { int32_t lat, lon; if (_core->gpsEnabled()) snprintf(sub, n, "From the GPS while it's on"); - else if (_core->fixedPosition(lat, lon)) snprintf(sub, n, "%.5f, %.5f", lat / 1e6, lon / 1e6); + else if (_core->fixedPosition(lat, lon)) geo::fmtLatLon(sub, n, lat, lon, true); else snprintf(sub, n, "Not set"); } @@ -230,7 +206,7 @@ void UITask::ownPositionPick(int code) { if (code == OWNPOS_TYPE) { int32_t lat, lon; char cur[32] = ""; - if (_core->fixedPosition(lat, lon)) snprintf(cur, sizeof(cur), "%.5f, %.5f", lat / 1e6, lon / 1e6); + if (_core->fixedPosition(lat, lon)) geo::fmtLatLon(cur, sizeof(cur), lat, lon, true); TextEntry t = { "Fixed position", onOwnPosKb }; t.text = cur; t.hint = "50.06143, 19.93658"; @@ -246,7 +222,7 @@ void UITask::ownPositionPick(int code) { const Waypoint& w = wp.at(i); char title[WAYPOINT_LABEL_LEN + 8], sub[40]; snprintf(title, sizeof(title), UI_SYMBOL_FLAG " %s", w.label[0] ? w.label : "(unnamed)"); - snprintf(sub, sizeof(sub), "%.5f, %.5f", w.lat_1e6 / 1e6, w.lon_1e6 / 1e6); + geo::fmtLatLon(sub, sizeof(sub), w.lat_1e6, w.lon_1e6, true); listRow(list, title, sub, onOwnPosPick, (void*)(uintptr_t)(OWNPOS_WP0 + i)); } return; @@ -488,9 +464,7 @@ void UITask::schemaRow(lv_obj_t* card, int i) { return; } if (!st.option) { - lv_obj_t* sw = switchRow(card, st.label, st.hint, nullptr); - if (v) lv_obj_add_state(sw, LV_STATE_CHECKED); - lv_obj_add_event_cb(sw, onSchemaSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)i); + switchRow(card, st.label, st.hint, v, onSchemaSwitch, (void*)(uintptr_t)i); return; } // The longest list, the time zone, is 27 options and 167 bytes: at 160 @@ -584,16 +558,9 @@ void UITask::settingsGroup(int i) { g = group(body, "CONNECTIONS"); bluetoothRow(g); wifiRow(g); - if (_core->gpsAvailable()) { - lv_obj_t* sw = switchRow(g, LV_SYMBOL_GPS " GPS", "Tap for satellites and options", nullptr); - if (_core->gpsEnabled()) lv_obj_add_state(sw, LV_STATE_CHECKED); - lv_obj_add_event_cb(sw, onGpsSwitch, LV_EVENT_VALUE_CHANGED, NULL); - lv_obj_t* row = lv_obj_get_parent(sw); - rowPressable(row); - lv_obj_add_event_cb(row, [](lv_event_t* e) { - if (lv_event_get_target(e) == lv_event_get_current_target(e)) onOpenGpsFromSettings(e); // not the switch - }, LV_EVENT_CLICKED, NULL); - } + if (_core->gpsAvailable()) + switchRowOpens(switchRow(g, LV_SYMBOL_GPS " GPS", "Tap for satellites and options", _core->gpsEnabled(), onGpsSwitch), + onOpenGpsFromSettings); break; case 2: diff --git a/examples/companion_radio/ui-lvgl/SosScreen.h b/examples/companion_radio/ui-lvgl/SosScreen.h index 046f9001..a83e2ae1 100644 --- a/examples/companion_radio/ui-lvgl/SosScreen.h +++ b/examples/companion_radio/ui-lvgl/SosScreen.h @@ -352,7 +352,7 @@ void UITask::sosRow(int row) { for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { ChannelDetails ch; if (!the_mesh.getChannel(i, ch) || !ch.name[0]) continue; - switchRowIdx(g, ch.name, nullptr, p->sos_ch_mask & (1ULL << i), onSosChannel, i); + switchRow(g, ch.name, nullptr, (p->sos_ch_mask >> i) & 1, onSosChannel, (void*)(uintptr_t)i); n++; } if (n == 0) label(list, "No channels.", THEME_FONT_SMALL, theme::TEXT_MUTED); diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 5b1c92f8..809094f9 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -930,9 +930,9 @@ void UITask::setGps(bool on) { void UITask::botSetGPS(bool on) { if (_core->setGpsEnabled(on)) refreshStatusBar(); } -bool UITask::ensureGps() { +bool UITask::ensureGps(GpsAsk what) { int32_t lat, lon; - if (_core->gpsAvailable() && !_core->gpsEnabled()) { setGps(true); return false; } // off, its last fix still reads valid + if (gpsAsked(what)) return false; // off, its last fix still reads valid if (_core->course.currentLocation(lat, lon)) return true; showToast("Waiting for a GPS fix"); return false; @@ -1516,6 +1516,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) { if (!strncmp(name, "accent@", 7)) { theme::setAccent(atoi(name + 7)); return; } // "accent@4": Cyan if (!strncmp(name, "map@", 4)) { s_ui->simMapAt(name + 4); return; } // "map@lat,lon,z" if (!strncmp(name, "channel@", 8)) { s_ui->openChannel(atoi(name + 8)); return; } + if (!strcmp(name, "chanedit")) { s_ui->showChannelEdit(-1); return; } // a new channel if (!strncmp(name, "compose@", 8)) { s_ui->simCompose(name + 8); return; } if (!strcmp(name, "dm")) { s_ui->simDm(); return; } if (!strcmp(name, "send")) { s_ui->simSend(); return; } diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 2e1dd1ad..fc472a01 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -404,7 +404,12 @@ public: void pinPopup(bool channel, uint8_t ch_idx, const uint8_t* pub_key); void pinTo(int slot); void setGps(bool on); - bool ensureGps(); // true with a fix; else turns GPS on / says it's waiting + // What needs the GPS while it's off: asked first ("GPS is off"), then done + // once it's turned on (GpsScreen.h). + enum GpsAsk : uint8_t { GA_TRAIL, GA_SHARE, GA_TRACKBACK, GA_CENTRE, GA_FIX }; + bool gpsAsked(GpsAsk what); // true: it was off, the question is up + bool ensureGps(GpsAsk what); // true with a fix; else asks, or says it's waiting + void gpsAskDone(GpsAsk what); // Kept for main.cpp's sim hooks (ui-new API). void openContactDM(const ContactInfo& ci) { openDM(ci.id.pub_key); } diff --git a/examples/companion_radio/ui-lvgl/Widgets.h b/examples/companion_radio/ui-lvgl/Widgets.h index 721faeec..f425594f 100644 --- a/examples/companion_radio/ui-lvgl/Widgets.h +++ b/examples/companion_radio/ui-lvgl/Widgets.h @@ -1071,6 +1071,19 @@ static void stylePrimary(lv_obj_t* b) { lv_obj_set_style_text_color(lv_obj_get_child(b, i), lv_color_hex(theme::ON_ACCENT), 0); } +// The one main action under a form or a list, full width (Save, Measure ...). +// Its label is child 0. +static lv_obj_t* primaryButton(lv_obj_t* parent, const char* text, lv_event_cb_t cb, void* user = nullptr) { + lv_obj_t* b = lv_button_create(parent); + lv_obj_set_size(b, LV_PCT(100), 40); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_set_style_radius(b, theme::RADIUS, 0); + lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, user); + lv_obj_center(label(b, text, THEME_FONT_BODY, theme::TEXT)); + stylePrimary(b); + return b; +} + // Something selected / on (a tab, a chip, a row): SEL_BG with SEL_FG text // (Theme.h), or back to the surface with TEXT. Every label under it follows. static void textUnder(lv_obj_t* o, uint32_t col) { @@ -1256,6 +1269,52 @@ static void prefsFlush() { the_mesh.savePrefs(); } +// A switch, one size on every screen. +static lv_obj_t* switchCreate(lv_obj_t* parent, bool on, lv_event_cb_t cb, void* user) { + lv_obj_t* sw = lv_switch_create(parent); + lv_obj_set_size(sw, 46, 24); + if (on) lv_obj_add_state(sw, LV_STATE_CHECKED); + if (cb) lv_obj_add_event_cb(sw, cb, LV_EVENT_VALUE_CHANGED, user); + return sw; +} + +// A preference toggle (0/1 byte in NodePrefs), saved on change. +static void onPrefSwitch(lv_event_t* e) { + uint8_t* pref = (uint8_t*)lv_event_get_user_data(e); + *pref = lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED) ? 1 : 0; + prefsSave(); +} + +// A settings row with a switch on the right. pref == nullptr: the caller wires +// the switch itself. +static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref) { + lv_obj_t* row = settingRow(parent, text, sub, 236); // the hint clear of the switch + lv_obj_t* sw = switchCreate(row, pref && *pref, pref ? onPrefSwitch : nullptr, pref); + lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); + return sw; +} + +// The same for a switch that isn't a plain pref: `cb` gets `user` on a change. +static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, bool on, lv_event_cb_t cb, + void* user = nullptr) { + lv_obj_t* sw = switchRow(parent, text, sub, nullptr); + if (on) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, cb, LV_EVENT_VALUE_CHANGED, user); + return sw; +} + +// A switch row whose text opens the details (`open`) while the switch only +// switches: Bluetooth, WiFi, GPS. +static void onSwitchRowTap(lv_event_t* e) { + if (lv_event_get_target(e) != lv_event_get_current_target(e)) return; // the switch + ((lv_event_cb_t)lv_event_get_user_data(e))(e); +} +static void switchRowOpens(lv_obj_t* sw, lv_event_cb_t open) { + lv_obj_t* row = lv_obj_get_parent(sw); + rowPressable(row); + lv_obj_add_event_cb(row, onSwitchRowTap, LV_EVENT_CLICKED, (void*)open); +} + // The big clock in the boot splash's lettering, as ui-new draws it on the // OLED and e-ink (ui-core/Lettering.h: block digits in 3 px strokes): HH:MM // at SC x, the seconds after them at SC_SEC x on the same baseline. The diff --git a/examples/companion_radio/ui-lvgl/WifiScreen.h b/examples/companion_radio/ui-lvgl/WifiScreen.h index ddda9377..965d7fb4 100644 --- a/examples/companion_radio/ui-lvgl/WifiScreen.h +++ b/examples/companion_radio/ui-lvgl/WifiScreen.h @@ -72,9 +72,7 @@ void UITask::buildWifi() { { // the same switch as Settings' row char sub[48]; wifiHint(sub, sizeof(sub)); - lv_obj_t* sw = switchRow(group(body, nullptr), LV_SYMBOL_WIFI " WiFi", sub, nullptr); - if (lvport::wifiAllowed()) lv_obj_add_state(sw, LV_STATE_CHECKED); - lv_obj_add_event_cb(sw, onWifiAllowed, LV_EVENT_VALUE_CHANGED, NULL); + switchRow(group(body, nullptr), LV_SYMBOL_WIFI " WiFi", sub, lvport::wifiAllowed(), onWifiAllowed); groupNote(body, "For map downloads, live map tiles and updates."); } @@ -129,13 +127,7 @@ void UITask::buildWifi() { _wifi_pass = wifiField(body, "Password", true); - lv_obj_t* save = lv_button_create(body); - lv_obj_set_size(save, LV_PCT(100), 38); - lv_obj_set_style_shadow_width(save, 0, 0); - lv_obj_set_style_radius(save, theme::RADIUS, 0); - lv_obj_add_event_cb(save, onWifiSave, LV_EVENT_CLICKED, NULL); - lv_obj_center(label(save, LV_SYMBOL_OK " Save", THEME_FONT_BODY, theme::TEXT)); - stylePrimary(save); + primaryButton(body, LV_SYMBOL_OK " Save", onWifiSave); _wifi_status = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED); // Keyboard over the bottom of the screen; the body shrinks above it while @@ -182,14 +174,8 @@ void UITask::wifiKeyboardHide() { static void wifiRow(lv_obj_t* body) { char sub[48]; wifiHint(sub, sizeof(sub)); - lv_obj_t* sw = switchRow(body, LV_SYMBOL_WIFI " WiFi", sub, nullptr); - if (lvport::wifiAllowed()) lv_obj_add_state(sw, LV_STATE_CHECKED); - lv_obj_add_event_cb(sw, onWifiAllowed, LV_EVENT_VALUE_CHANGED, NULL); - lv_obj_t* row = lv_obj_get_parent(sw); - rowPressable(row); - lv_obj_add_event_cb(row, [](lv_event_t* e) { - if (lv_event_get_target(e) == lv_event_get_current_target(e)) s_ui->showWifi(); // not the switch - }, LV_EVENT_CLICKED, NULL); + switchRowOpens(switchRow(body, LV_SYMBOL_WIFI " WiFi", sub, lvport::wifiAllowed(), onWifiAllowed), + [](lv_event_t* e) { (void)e; s_ui->showWifi(); }); } void UITask::wifiSetAllowed(bool on) { diff --git a/examples/companion_radio/ui-new/HomeScreen.h b/examples/companion_radio/ui-new/HomeScreen.h index 62e65b19..028f755e 100644 --- a/examples/companion_radio/ui-new/HomeScreen.h +++ b/examples/companion_radio/ui-new/HomeScreen.h @@ -1614,6 +1614,7 @@ public: display.setTextSize(1); display.drawTextCentered(display.width() / 2, content_y + step, "hibernating..."); // the panel's Hibernate row } + if (_page == HomePage::MAP && _tile_menu.active) _tile_menu.render(display); // "GPS is off" if (slide >= 0) { // page turn: the page left behind slides out, this one in display.slideCompose(_slide_dir * (int)(display.width() * slide + 0.5f)); anim_ms = 15; @@ -1634,6 +1635,14 @@ public: } bool handleInput(char c) override { + // Map page: "GPS is off" before a share (the tile menu, free on this page). + if (_page == HomePage::MAP && _tile_menu.active) { + if (_tile_menu.handleInput(c) == PopupMenu::SELECTED && _tile_menu.selectedIndex() == 0) { + _task->toggleGPS(false); + _task->showAlert("GPS on, share once it has a fix", 1500); + } + return true; + } // Favourites grid claims joystick UP/DOWN and inner LEFT/RIGHT; LEFT at the // left column and RIGHT at the right column fall through to page nav so the // user can still leave the page sideways. @@ -1759,6 +1768,12 @@ public: return true; } if (c == KEY_CONTEXT_MENU && _page == HomePage::MAP) { + if (_task->hasGPS() && !_task->getGPSState()) { // its last fix may be stale: asked first + _tile_menu.begin("GPS is off", 2); + _tile_menu.addItem("Enable GPS"); + _tile_menu.addItem("Cancel"); + return true; + } _task->quickShareMyLocation(); return true; } diff --git a/examples/companion_radio/ui-new/LiveShareScreen.h b/examples/companion_radio/ui-new/LiveShareScreen.h index d9a1f2f1..6438a85b 100644 --- a/examples/companion_radio/ui-new/LiveShareScreen.h +++ b/examples/companion_radio/ui-new/LiveShareScreen.h @@ -11,6 +11,7 @@ #include "InfoKit.h" #include "../NodePrefs.h" #include "icons.h" // drawList (shared scrolling-list helper) +#include "PopupMenu.h" // "GPS is off" before Auto share starts class LiveShareScreen : public UIScreen { UITask* _task; @@ -18,6 +19,7 @@ class LiveShareScreen : public UIScreen { bool _dirty = false; int _sel = 0; int _scroll = 0; + PopupMenu _gps_ask; enum Kind : uint8_t { K_TRACK, K_AUTO, K_DURATION, K_TARGET, K_SCOPE, K_MOVE, K_GAP, K_HB }; struct Row { Kind kind; const char* label; }; @@ -39,7 +41,7 @@ class LiveShareScreen : public UIScreen { public: LiveShareScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} - void onShow() override { _dirty = false; _sel = 0; _scroll = 0; } + void onShow() override { _dirty = false; _sel = 0; _scroll = 0; _gps_ask.active = false; } // Resolve the configured target's display name (channel name or contact name). void currentTargetName(char* buf, int n) { @@ -112,6 +114,7 @@ public: int mqr = info::listRow(display, y, r.label, val, sel, reserve); if (mqr > 0) mq_delay = mqr; }); + if (_gps_ask.active) _gps_ask.render(display); return (mq_delay > 0 && mq_delay < 500) ? mq_delay : 500; } @@ -124,8 +127,14 @@ public: switch (rows(_sel).kind) { case K_TRACK: _prefs->track_shared_loc ^= 1; _dirty = true; break; case K_AUTO: - _prefs->loc_share_enabled ^= 1; - if (_prefs->loc_share_enabled) _task->restartLocShareSession(); + // With the GPS off nothing would go out: asked first, as Trail does. + if (!_prefs->loc_share_enabled && _task->hasGPS() && !_task->getGPSState()) { + _gps_ask.begin("GPS is off", 2); // the action first, as Trail's: nothing is lost + _gps_ask.addItem("Enable GPS & share"); + _gps_ask.addItem("Cancel"); + return; + } + _task->core().live_share.setEnabled(!_prefs->loc_share_enabled); _dirty = true; break; case K_DURATION: _prefs->loc_share_duration_idx = (uint8_t)((_prefs->loc_share_duration_idx + (dir >= 0 ? 1 : NodePrefs::LOC_SHARE_DURATION_COUNT - 1)) % NodePrefs::LOC_SHARE_DURATION_COUNT); @@ -192,6 +201,15 @@ public: } bool handleInput(char c) override { + if (_gps_ask.active) { + if (_gps_ask.handleInput(c) == PopupMenu::SELECTED && _gps_ask.selectedIndex() == 0) { + _task->toggleGPS(false); + _task->core().live_share.setEnabled(true); + _dirty = true; + _task->showAlert("GPS on, sharing", 1200); + } + return true; + } if (c == KEY_CANCEL) { _task->savePrefsIfDirty(_dirty); _task->leaveTool(); diff --git a/examples/companion_radio/ui-new/NavView.h b/examples/companion_radio/ui-new/NavView.h index bd55255d..8f67e2d2 100644 --- a/examples/companion_radio/ui-new/NavView.h +++ b/examples/companion_radio/ui-new/NavView.h @@ -14,8 +14,6 @@ namespace navview { -static const int AHEAD_DEG = 12; // within this of straight on: "ahead" - // own_valid : is there a usable GPS fix for the device right now // cog_valid : is there a stable course-over-ground (UITask::currentCourse) // eta : optional closing-speed/ETA tracker; when supplied a third line is @@ -89,8 +87,8 @@ inline void draw(DisplayDriver& d, } } int turn = 0; // -180..179 degrees to turn, right positive; with a course - if (cog_valid) turn = ((to_deg - cog_deg + 540) % 360) - 180; - const bool ahead = cog_valid && turn <= AHEAD_DEG && turn >= -AHEAD_DEG; + if (cog_valid) turn = geo::turnDeg(to_deg, cog_deg); + const bool ahead = cog_valid && geo::isAhead(turn); // Distance — emphasised at size 2. d.setTextSize(2); diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index ba470491..4e1f2458 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -861,7 +861,7 @@ class NearbyScreen : public UIScreen, protected NearbyModel { // row, unlabelled: then there are five rows either way. const bool one_pos = top + step * 5 + display.getLineHeight() > bottom; if (one_pos) { - snprintf(buf, sizeof(buf), "%.5f,%.5f", e.lat_e6 / 1e6, e.lon_e6 / 1e6); + geo::fmtLatLon(buf, sizeof(buf), e.lat_e6, e.lon_e6); display.drawTextEllipsized(tx, top, w - 4, buf); } else { snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6); diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index d34aeaa4..ad172253 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -964,7 +964,7 @@ public: } else if (sel == 0) { // Type in int32_t lat, lon; char cur[32] = ""; - if (_task->core().fixedPosition(lat, lon)) snprintf(cur, sizeof(cur), "%.5f, %.5f", lat / 1e6, lon / 1e6); + if (_task->core().fixedPosition(lat, lon)) geo::fmtLatLon(cur, sizeof(cur), lat, lon, true); _edit_pos = true; _kb->begin(cur, 31); _kb->prompt = "Lat, lon"; diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index f668ca75..f9a534cf 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -81,7 +81,7 @@ class TrailScreen : public UIScreen { // correctly (settings rows cycle with LEFT/RIGHT; everything else is Enter). // Live-share *config* lives in its own tool (Tools › Live Share); the map only // keeps the one-shot "Share my pos" action. - enum MenuLevel { ML_MAIN, ML_FILE, ML_SETTINGS, ML_CONFIRM_GPS, ML_CONFIRM_RESET }; + enum MenuLevel { ML_MAIN, ML_FILE, ML_SETTINGS, ML_CONFIRM_GPS, ML_CONFIRM_GPS_SHARE, ML_CONFIRM_RESET }; PopupMenu _action_menu; uint8_t _menu_level = ML_MAIN; uint8_t _act_map[16]; // max rows on any one level; pushAction guards the cap @@ -179,6 +179,11 @@ public: _menu_level = ML_MAIN; // popup already closed by handleInput() return true; } + if (_menu_level == ML_CONFIRM_GPS_SHARE) { // "Enable GPS": a fix takes a while, so no send yet + if (_action_menu.selectedIndex() == 0) { _task->toggleGPS(false); _task->showAlert("GPS on, share once it has a fix", 1500); } + _menu_level = ML_MAIN; + return true; + } // Reset confirmation popup: same shape, but defaults to Cancel (row 1) // since unlike the GPS prompt above, this one is destructive. if (_menu_level == ML_CONFIRM_RESET) { @@ -198,13 +203,16 @@ public: case ACT_MARK_AVG: case ACT_AUTOSAVE: case ACT_AUTOPAUSE: return true; - case ACT_SHARE_NOW: shareMyLocationNow(); break; + case ACT_SHARE_NOW: + if (_task->hasGPS() && !_task->getGPSState()) { buildGpsConfirmMenu(true); return true; } // its last fix may be stale + shareMyLocationNow(); + break; case ACT_TOGGLE: // Starting a trail with GPS switched off logs nothing and just // sits on "Waiting for GPS fix" forever -- prompt to enable it // first (only on boards that actually have a toggleable GPS). if (!_store->isActive() && _task->hasGPS() && !_task->getGPSState()) { - buildGpsConfirmMenu(); + buildGpsConfirmMenu(false); return true; } handleToggle(); @@ -379,13 +387,14 @@ private: pushAction(ACT_SETTINGS, "Settings..."); } - // Confirmation shown when "Start tracking" is chosen with GPS off. Rows are - // plain (not ActionIds); handled by _menu_level in the SELECTED branch. - void buildGpsConfirmMenu() { - _menu_level = ML_CONFIRM_GPS; + // Confirmation shown when "Start tracking" or "Share my pos" is chosen with + // GPS off. Rows are plain (not ActionIds); handled by _menu_level in the + // SELECTED branch. + void buildGpsConfirmMenu(bool share) { + _menu_level = share ? ML_CONFIRM_GPS_SHARE : ML_CONFIRM_GPS; _act_count = 0; _action_menu.begin("GPS is off", 2); - _action_menu.addItem("Enable GPS & start"); + _action_menu.addItem(share ? "Enable GPS" : "Enable GPS & start"); _action_menu.addItem("Cancel"); } @@ -471,16 +480,8 @@ private: p->gps_avg_idx = idx; } - // One-shot manual share: build "[LOC]lat,lon" and hand it to the Messages - // screen, where the user picks a DM or channel recipient. (Auto live-share - // config lives in Tools › Live Share.) - void shareMyLocationNow() { - int32_t lat, lon; - if (!_task->currentLocation(lat, lon)) { _task->showAlert("No GPS fix", 1000); return; } - char text[40]; - snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f", lat / 1e6, lon / 1e6); - _task->shareToMessage(text); - } + // One-shot manual share, as the home Map page's: UITask::quickShareMyLocation. + void shareMyLocationNow() { _task->quickShareMyLocation(); } // Render the average speed (or pace) in the globally-selected unit system. void formatAvgPaceOrSpeed(char* buf, size_t n, NodePrefs* p) const { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 762468c4..fd5bbdba 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1907,7 +1907,6 @@ bool UITask::currentCourse(int& deg_out) const { return _core->course.currentCou bool UITask::currentLocation(int32_t& lat, int32_t& lon) const { return _core->course.currentLocation(lat, lon); } bool UITask::sendLocationShare(int32_t lat, int32_t lon) { return _core->live_share.send(lat, lon); } -void UITask::restartLocShareSession() { _core->live_share.restartSession(); } void UITask::restartLocShareClock() { _core->live_share.restartClock(); } LiveTrackStore& UITask::liveTrack() { return _core->live_share.track(); } @@ -1923,7 +1922,7 @@ void UITask::quickShareMyLocation() { return; } char text[40]; - snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f", lat / 1e6, lon / 1e6); + geo::fmtLocShare(text, sizeof(text), lat, lon); shareToMessage(text); } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 083c3a4a..22098d27 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -366,7 +366,6 @@ public: void gotoLiveShareScreen(); // Live share session control (ui-core/LiveShareEngine.h): restart = new // session (re-announce + fresh clock); clock-only = changed "Stop after". - void restartLocShareSession(); void restartLocShareClock(); void gotoLocatorScreen(); // Re-arm the locator state machine so the next evaluation initialises diff --git a/examples/companion_radio/ui-new/WaypointsView.h b/examples/companion_radio/ui-new/WaypointsView.h index 78ebcd27..c7009a8e 100644 --- a/examples/companion_radio/ui-new/WaypointsView.h +++ b/examples/companion_radio/ui-new/WaypointsView.h @@ -388,11 +388,8 @@ public: } } else if (sel == 2) { // Send (share in a message) if (wi >= 0 && wi < _task->waypoints().count()) { - const Waypoint& w = _task->waypoints().at(wi); - double lat = w.lat_1e6 / 1000000.0, lon = w.lon_1e6 / 1000000.0; char text[80]; - if (w.label[0]) snprintf(text, sizeof(text), WAYPOINT_MSG_TAG "%.5f,%.5f %s", lat, lon, w.label); - else snprintf(text, sizeof(text), WAYPOINT_MSG_TAG "%.5f,%.5f", lat, lon); + _task->core().waypoints.shareText(wi, text, sizeof(text)); _task->shareToMessage(text); // hands off to the Messages screen } } else { // Set as target diff --git a/test/test_sos_engine/test_sos_engine.cpp b/test/test_sos_engine/test_sos_engine.cpp index cda8b7d7..df995c78 100644 --- a/test/test_sos_engine/test_sos_engine.cpp +++ b/test/test_sos_engine/test_sos_engine.cpp @@ -176,6 +176,33 @@ TEST(SosText, OnlyAnSosWithAPositionIsTakenForOne) { EXPECT_FALSE(geo::parseSos("SOSNA 50.06143,19.93658", lat, lon)); } +// The [LOC] share every sender builds, and what the receiver reads back. +TEST(GeoText, LocationShareRoundTrips) { + char t[64]; + geo::fmtLocShare(t, sizeof(t), 50061430, -19936580); + EXPECT_STREQ(t, "[LOC]50.06143,-19.93658"); + geo::fmtLocShare(t, sizeof(t), 50061430, -19936580, "Hiker"); + EXPECT_STREQ(t, "[LOC]50.06143,-19.93658 Hiker"); + int32_t lat = 0, lon = 0; + ASSERT_TRUE(geo::parseLocShare(t, lat, lon)); + EXPECT_EQ(lat, 50061430); + EXPECT_EQ(lon, -19936580); + geo::fmtLatLon(t, sizeof(t), 50061430, 19936580, true); + EXPECT_STREQ(t, "50.06143, 19.93658"); + ASSERT_TRUE(geo::parseLatLon(t, lat, lon)); // an edited value reads back + EXPECT_EQ(lon, 19936580); +} + +TEST(GeoText, TurnIsTheShortWayRound) { + EXPECT_EQ(geo::turnDeg(90, 0), 90); + EXPECT_EQ(geo::turnDeg(0, 90), -90); + EXPECT_EQ(geo::turnDeg(10, 350), 20); + EXPECT_EQ(geo::turnDeg(350, 10), -20); + EXPECT_EQ(geo::turnDeg(180, 0), -180); + EXPECT_TRUE(geo::isAhead(geo::turnDeg(5, 355))); + EXPECT_FALSE(geo::isAhead(geo::turnDeg(20, 0))); +} + TEST(SosText, NoGpsIsRecognisedOnItsOwn) { char t[64]; SosEngine::compose(t, sizeof(t), SosEngine::NOWHERE, 0, 0, nullptr, -1, "14:32", "");