diff --git a/.github/workflows/tmap-dependency.yml b/.github/workflows/tmap-dependency.yml index ce46a46f..2bb6ce99 100644 --- a/.github/workflows/tmap-dependency.yml +++ b/.github/workflows/tmap-dependency.yml @@ -9,6 +9,7 @@ on: - 'modules/ui_map_runtime/**' - 'builds/esp_idf/**' - 'tests/tmap/**' + - 'tests/navigation/**' - '.github/workflows/tmap-dependency.yml' pull_request: paths: @@ -18,6 +19,7 @@ on: - 'modules/ui_map_runtime/**' - 'builds/esp_idf/**' - 'tests/tmap/**' + - 'tests/navigation/**' - '.github/workflows/tmap-dependency.yml' jobs: native: diff --git a/apps/linux_cardputer_zero/tests/linux_cardputer_zero_business_wiring_smoke.cpp b/apps/linux_cardputer_zero/tests/linux_cardputer_zero_business_wiring_smoke.cpp index 4474a281..00e58bda 100644 --- a/apps/linux_cardputer_zero/tests/linux_cardputer_zero_business_wiring_smoke.cpp +++ b/apps/linux_cardputer_zero/tests/linux_cardputer_zero_business_wiring_smoke.cpp @@ -199,8 +199,9 @@ int main(int argc, char** argv) assert(contains(gps_runtime, "show_team_overlay_notice")); assert(contains(gps_runtime, "route_context_available")); assert(contains(gps_runtime, "config.route_enabled && config.route_path[0] != '\\0'")); - assert(contains(gps_runtime, "load_team_snapshot(team_snapshot) && !team_snapshot.members.empty()")); - assert(contains(gps_runtime, "create_member_button(member)")); + assert(contains(gps_runtime, "open_map_menu(bool team)")); + assert(contains(gps_runtime, "load_team_snapshot(snapshot)")); + assert(contains(gps_runtime, "create_member_button(member, panel)")); assert(contains(gps_runtime, "select_member(member_id)")); assert(contains(gps_runtime, "lv_obj_align(s_map_notice_panel, LV_ALIGN_TOP_LEFT")); assert(contains(gps_runtime, "lv_obj_set_style_bg_opa(s_map_notice_panel, LV_OPA_70")); @@ -251,7 +252,7 @@ int main(int argc, char** argv) assert(contains(gps_runtime, "request_open_map_help_modal")); assert(contains(gps_runtime, "lv_async_call(open_map_help_modal_async, nullptr)")); assert(contains(gps_runtime, "lv_obj_add_flag(s_map_help_modal, LV_OBJ_FLAG_IGNORE_LAYOUT)")); - assert(contains(gps_runtime, "lv_label_set_text(title, \"Map Help\")")); + assert(contains(gps_runtime, "::ui::i18n::set_label_text(title, \"Map Help\")")); assert(contains(gps_runtime, "add_help_row(\"WASD\", nullptr, \"Move map\")")); assert(contains(gps_runtime, "add_help_row(\"Q\", \"E\", \"Zoom map\")")); assert(contains(gps_runtime, "add_help_row(\"C\", \"Pos\", \"Center current position\")")); diff --git a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake index da97403c..eee86c3e 100644 --- a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake +++ b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake @@ -340,6 +340,7 @@ set(TRAILMATE_ESP_IDF_UI_SHARED_SOURCES "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_transfer_leader_action.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/tracker/tracker_page_input.cpp" + "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/tracker/tracker_navigation_dialog.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/tracker/tracker_page_layout.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/tracker/tracker_page_runtime.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/tracker/tracker_page_shell.cpp" diff --git a/cmake/TrailMateLinuxSources.cmake b/cmake/TrailMateLinuxSources.cmake index a3f9e804..13a66985 100644 --- a/cmake/TrailMateLinuxSources.cmake +++ b/cmake/TrailMateLinuxSources.cmake @@ -517,6 +517,7 @@ set(TRAIL_MATE_LINUX_UI_SHELL_SOURCES # tracker "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/tracker/tracker_page_components.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/tracker/tracker_page_input.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/tracker/tracker_navigation_dialog.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/tracker/tracker_page_layout.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/tracker/tracker_page_runtime.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/tracker/tracker_page_shell.cpp" diff --git a/docs/uiux/pages/map_taskbar.md b/docs/uiux/pages/map_taskbar.md new file mode 100644 index 00000000..c99d2081 --- /dev/null +++ b/docs/uiux/pages/map_taskbar.md @@ -0,0 +1,34 @@ +# Map taskbar and upward menus + +Status: implemented in the shared Map runtime; native syntax validation passes. +Touch layout and the new navigation integration still require acceptance. + +The bottom bar keeps minus, zoom level and plus at the left. A flexible spacer +places Pos, Help, Team and Menu at the right, in that order. The bar does not +scroll horizontally. Location-picking mode retains its existing Pick/Cancel +actions instead of exposing unrelated tools. + +Menu opens upward above its button and contains the contour visibility toggle, +current map layer, POI search, Load navigation and Load track. These invoke the +existing layer, contour, search and file-loading behavior. Navigation selects a +saved navigation KML and activates it for route following; recorded tracks +remain a separate choice. + +Team is a separate upward menu next to Menu. It directly exposes the existing +member-selection and member-track actions. Member controls are created when +Team opens rather than maintained in a permanently visible side rail. Only +one upward menu is open at a time. Repeating its button or tapping the map +outside dismisses it. Keyboard Escape/Back and the M shortcut remain supported. + +Popup height is constrained to the map area above the taskbar, with vertical +scrolling when necessary. Popup controls are destroyed on dismissal. Pos and +Help stay accessible while a popup is open. + +Help has a fixed header with a visible Close touch button. Only the help body +scrolls, so Close cannot disappear below the content. The panel is constrained +to the screen width/height. Existing keyboard dismissal remains available. + +Acceptance must cover Wio L2 touch at 320 x 240, Pager keyboard input, narrow +layouts, each menu action, switching menus, popup dismissal, team selection, +and opening/scrolling/closing Help without any keyboard event. No physical +device acceptance is claimed by the syntax check. diff --git a/docs/uiux/pages/tracker_navigation.md b/docs/uiux/pages/tracker_navigation.md new file mode 100644 index 00000000..8b0db3a6 --- /dev/null +++ b/docs/uiux/pages/tracker_navigation.md @@ -0,0 +1,109 @@ +# Tracker: origin-to-destination navigation + +Status: the ESP runtime, Tracker filter, shared POI endpoint form, KML save and +existing map-preview/load integration are implemented. Focused calculation, +serialization and native LVGL dialog tests pass; the changed sources also pass +Wio L2 toolchain syntax validation. Physical navigation acceptance is pending. + +## User flow + +Tracker keeps its Tracks and Routes filters and adds Navigation. The new filter +shows saved navigation plans using the existing single-card previous/next +interaction. A New navigation action is available even when there are no plans. + +Creating a navigation opens an origin/destination form. The origin defaults to +Current location; its coordinates are resolved from a valid GPS fix when the +user requests calculation. Tapping either endpoint opens +the existing `ui::components::map_poi_search` picker. Its selected POI supplies +the endpoint coordinates and display name. Typing a place name does not itself +choose coordinates. Returning from search preserves the other endpoint. + +Calculate produces a road/path route through the installed TMAP routing data. +The UI remains responsive and offers cancellation while calculation runs. The +result reports its distance and enables Save and preview. Saving opens the +existing route preview with the computed path and distance. The user can change +endpoints before saving or load the saved plan for navigation. A saved plan can +later be previewed, navigated or deleted from its +Tracker card. Saving preserves the selected endpoints and computed geometry; +opening an existing plan does not silently replace its origin with a new fix. + +Starting navigation activates the computed path in the existing GPS map. +Current position, remaining route distance, route progress, deviation and +arrival use the existing route-following facilities. Stopping navigation ends +the active session without deleting the saved plan. Cards and endpoint fields +prefer place names, administrative area and distance over raw coordinates. + +The Map taskbar also offers Menu > Load navigation for direct access to saved +plans. Team has its own separate upward menu; see [Map taskbar](map_taskbar.md). + +```mermaid +flowchart LR + Tracker[Tracker: Navigation] --> New[New navigation] + Tracker --> Saved[Saved navigation card] + New --> Endpoints[Origin and destination] + Endpoints --> Current[Current location] + Endpoints --> Search[Existing POI search] + Current --> Calculate[TMAP path calculation] + Search --> Calculate + Calculate --> Preview[Computed path preview] + Preview --> Save[Save navigation] + Save --> Saved + Saved --> Preview + Preview --> Follow[Start navigation on GPS map] +``` + +## Integration boundaries + +- Reuse `map_poi_search::open` and its existing selection callback for endpoint + entry. Do not introduce another POI search implementation or dataset. +- Keep path calculation independent of LVGL and Tracker state. The TMAP SDK + already supplies `beginIntegerCoordinateRoute`, incremental routing steps + and streamed integer-route geometry. The application supplies bounded + working storage and a platform file adapter. +- Check routing capability and endpoint coverage in installed packages. A map + that renders tiles or supports POI search does not necessarily contain a + road graph. Explain missing routing data or no path; never draw a straight + line and present it as a calculated route. +- Persist plans and their KML route geometry on SD. Reuse existing map route + rendering and route-following integration after calculation completes. + Generated navigation is local data, not an automatically published trail. +- Install Center-produced `navigation.tmap` files in `/maps/navigation` or + `/maps/tmap/navigation`; existing `/maps/tmap` packages are also checked for + routing data. Saved plans live in `/routes/navigation` and have filenames + containing bounded origin/destination names plus an identifier. A raster-only + map is insufficient. This first device integration uses the foot profile and + one package per route; compose regional source data in Center for routes that + need a connected graph across region boundaries. Packages requiring runtime + conditional-rule evaluation are not supported by this integration. +- Allocate the SDK package workspace, search labels and route output only + while needed, in PSRAM on capable ESP targets. Release planning buffers + after save/cancel; do not keep another graph cache or worker stack alive + while Tracker is idle. Stream geometry into storage instead of retaining + the complete road network or a second full path in internal RAM. +- Keep existing recording, imported routes and shared-trail behavior intact. + Add firmware locale strings through the existing packs for the supported + languages. + +## Acceptance + +Verify current-position and searched origins, searched destinations, returning +from the common picker, successful road-graph calculation and map rendering, +save/reopen across restart, start/stop route following, and ordinary missing +GPS/routing-data/no-path feedback. Check touch and keyboard/card navigation at +the existing firmware sizes. Measure internal RAM before creating a plan, +during calculation and after cancellation/completion. This feature requires a +new firmware candidate; the previously consumed final flash is not reusable +without another device upload opportunity. + +Native checks are `tests/navigation` (real SDK routing and saved KML) and +`tests/navigation_ui` (the production dialog with fake GPS/storage/POI ports). +The latter checks both current-GPS and searched origins, destination selection, +the ready/save/preview callback, fixed touch cancellation and input-group/lease +restoration. It does not exercise SD I/O, real POI search or physical touch. + +On 2026-10-15 a separate host probe used the same RouteCalculation and KML +writer with Center's real Monaco foot/1 KiB/ALT4 package. It calculated a +571 m route with 21 legs and streamed 60 geometry points to KML. The SDK memory +provider peaked at 150,200 bytes and released its allocations on reset. These +are host measurements for this route, excluding platform SD/LVGL overhead; +they are not physical-device memory or performance measurements. diff --git a/modules/core_sys/include/platform/ui/navigation_runtime.h b/modules/core_sys/include/platform/ui/navigation_runtime.h new file mode 100644 index 00000000..a1683f69 --- /dev/null +++ b/modules/core_sys/include/platform/ui/navigation_runtime.h @@ -0,0 +1,60 @@ +#pragma once +#include +#include +#include +#include + +namespace platform::ui::navigation +{ +struct Endpoint +{ + int32_t latitude_e7 = 0, longitude_e7 = 0; + char name[161]{}; +}; +struct Request +{ + Endpoint origin{}, destination{}; +}; +enum class Status : uint8_t +{ + Idle, + Calculating, + Ready, + Saved, + NoRoutingData, + NoPath, + OutsideCoverage, + MemoryUnavailable, + StorageUnavailable, + Cancelled +}; +struct Snapshot +{ + Status status = Status::Idle; + uint32_t revision = 0, points = 0; + double distance_m = 0; + char path[160]{}; +}; +#if defined(ARDUINO_ARCH_ESP32) +bool supported(); +bool submit(const Request& request); +bool poll(Snapshot& snapshot); +bool save(); +void cancel(); +bool list(std::vector& names, size_t maximum = 64); +bool remove(const char* name); +const char* directory(); +#else +inline bool supported() +{ + return false; +} +inline bool submit(const Request&) { return false; } +inline bool poll(Snapshot&) { return false; } +inline bool save() { return false; } +inline void cancel() {} +inline bool list(std::vector&, size_t = 64) { return false; } +inline bool remove(const char*) { return false; } +inline const char* directory() { return "/routes/navigation"; } +#endif +} // namespace platform::ui::navigation diff --git a/modules/ui_map_runtime/include/ui_map_runtime/navigation/route_calculation.h b/modules/ui_map_runtime/include/ui_map_runtime/navigation/route_calculation.h new file mode 100644 index 00000000..046ece59 --- /dev/null +++ b/modules/ui_map_runtime/include/ui_map_runtime/navigation/route_calculation.h @@ -0,0 +1,173 @@ +#pragma once +#include "tmap/integer_coordinate_route.h" +#include "tmap/integer_geometry.h" +#include "tmap/outdoor_session.h" +#include "tmap/route_storage.h" + +namespace navigation +{ +// The platform owns the file/provider and schedules short steps. No LVGL, +// task, global workspace or allocation proportional to the full graph. +class RouteCalculation +{ + public: + enum class Phase : uint8_t + { + Idle, + Opening, + Allocating, + Beginning, + Searching, + Ready, + Geometry, + Complete, + Failed, + Cancelled + }; + struct Limits + { + size_t states = 4096; + size_t legs = 2048; + size_t search_bytes = 160 * 1024; + uint32_t expansions = 50000; + double snap_m = 100; + }; + explicit RouteCalculation(tmap::MemoryProvider& memory, tmap::MemoryPolicy policy = tmap::MemoryPolicy::PreferExternal) + : session_(memory, policy), storage_(memory, policy), search_(memory, policy), geometry_(memory, policy) {} + RouteCalculation(const RouteCalculation&) = delete; + RouteCalculation& operator=(const RouteCalculation&) = delete; + ~RouteCalculation() { reset(); } + + tmap::Result begin(tmap::RandomAccessFile& file, tmap::GeoPoint origin, tmap::GeoPoint destination, + tmap::TravelMode mode, Limits limits) + { + reset(); + origin_ = origin; + destination_ = destination; + mode_ = mode; + limits_ = limits; + tmap::SessionOptions options; + options.require_graph = true; + const auto result = session_.begin(file, options); + if (result != tmap::Result::More) return fail(result); + phase_ = Phase::Opening; + return result_ = tmap::Result::More; + } + tmap::Result step(size_t budget) + { + using R = tmap::Result; + if (!budget) return R::Invalid; + R result = result_; + switch (phase_) + { + case Phase::Opening: + result = session_.initializeStep(budget); + if (pending(result)) return result; + if (result != R::Ok) return fail(result); + result = storage_.begin(limits_.states, limits_.legs, limits_.search_bytes); + if (result != R::More) return fail(result); + phase_ = Phase::Allocating; + return result_ = R::More; + case Phase::Allocating: + result = storage_.initializeStep(budget); + if (pending(result)) return result; + if (result != R::Ok) return fail(result); + result = search_.create(); + if (result != R::Ok) return fail(result); + phase_ = Phase::Beginning; + return result_ = R::More; + case Phase::Beginning: + { + tmap::IntegerRouteOptions options; + options.mode = mode_; + options.max_expansions = limits_.expansions; + options.algorithm = tmap::IntegerRouteAlgorithm::HeapDijkstra; + result = tmap::beginIntegerCoordinateRoute(*session_.graph(), origin_, destination_, options, limits_.snap_m, + {storage_.labels(), storage_.queue(), storage_.states()}, + storage_.output(), storage_.outputs(), *search_.get()); + if (pending(result)) return result; + if (result != R::Ok) return fail(result); + phase_ = Phase::Searching; + return result_ = R::More; + } + case Phase::Searching: + result = tmap::integerCoordinateRouteStep(*session_.graph(), *search_.get(), budget); + if (pending(result)) return result; + if (result != R::Ok) return fail(result); + result = tmap::integerCoordinateRouteResult(*search_.get(), legs_, count_, cost_); + if (result != R::Ok) return fail(result); + phase_ = Phase::Ready; + return result_ = R::Ok; + case Phase::Geometry: + result = tmap::integerGeometryStep(*session_.graph(), *geometry_.get(), budget, visitor_, context_); + if (pending(result)) return result; + if (result != R::Ok) return fail(result); + phase_ = Phase::Complete; + return result_ = R::Ok; + default: + return result; + } + } + tmap::Result beginGeometry(tmap::RoutePointVisitor visitor, void* context) + { + if (phase_ != Phase::Ready || !visitor) return tmap::Result::Invalid; + auto result = geometry_.create(); + if (result != tmap::Result::Ok) return fail(result); + result = tmap::beginIntegerGeometry(*session_.graph(), legs_, count_, *geometry_.get()); + if (result != tmap::Result::Ok) return fail(result); + visitor_ = visitor; + context_ = context; + phase_ = Phase::Geometry; + return result_ = tmap::Result::More; + } + void reset() + { + if (search_.get()) tmap::cancel(*search_.get()); + if (geometry_.get()) tmap::cancel(*geometry_.get()); + geometry_.reset(); + search_.reset(); + storage_.reset(); + session_.reset(); + visitor_ = nullptr; + context_ = nullptr; + legs_ = nullptr; + count_ = 0; + cost_ = 0; + phase_ = Phase::Idle; + result_ = tmap::Result::Invalid; + } + void cancel() + { + reset(); + phase_ = Phase::Cancelled; + result_ = tmap::Result::Cancelled; + } + Phase phase() const { return phase_; } + tmap::Result result() const { return result_; } + size_t legCount() const { return count_; } + uint64_t costUnits() const { return cost_; } + static bool pending(tmap::Result result) { return result == tmap::Result::More || result == tmap::Result::Busy; } + + private: + tmap::Result fail(tmap::Result result) + { + reset(); + phase_ = Phase::Failed; + return result_ = result; + } + tmap::OutdoorSession session_; + tmap::IntegerPreparedRouteStorage storage_; + tmap::MemoryObject search_; + tmap::MemoryObject geometry_; + const tmap::IntegerRouteLeg* legs_ = nullptr; + tmap::RoutePointVisitor visitor_ = nullptr; + void* context_ = nullptr; + tmap::GeoPoint origin_{}, destination_{}; + Limits limits_{}; + size_t count_ = 0; + uint64_t cost_ = 0; + tmap::TravelMode mode_ = tmap::TravelMode::Foot; + Phase phase_ = Phase::Idle; + tmap::Result result_ = tmap::Result::Invalid; +}; +} // namespace navigation diff --git a/modules/ui_map_runtime/include/ui_map_runtime/navigation/route_kml_writer.h b/modules/ui_map_runtime/include/ui_map_runtime/navigation/route_kml_writer.h new file mode 100644 index 00000000..140b3ec1 --- /dev/null +++ b/modules/ui_map_runtime/include/ui_map_runtime/navigation/route_kml_writer.h @@ -0,0 +1,132 @@ +#pragma once +#include "tmap/reader.h" +#include +#include + +namespace navigation +{ +// Output is sequential and bounded. The platform sink owns file I/O and the +// temporary-file/rename lifecycle; it must not retain a borrowed write slice. +class RouteKmlWriter +{ + public: + using Write = bool (*)(void*, const char*, size_t); + bool begin(Write write, void* context, const char* origin_name, tmap::GeoPoint origin, + const char* destination_name, tmap::GeoPoint destination) + { + write_ = write; + context_ = context; + used_ = count_ = 0; + valid_ = write && origin_name && destination_name && pointValid(origin) && pointValid(destination); + if (!valid_) return false; + destination_ = destination; + // Keep endpoint strings in the caller's request until finish(). + destination_name_ = destination_name; + return text("") && + escaped(origin_name) && text(" - ") && escaped(destination_name) && text("") && + endpoint("origin", origin_name, origin) && + text("Navigation1"); + } + bool point(tmap::GeoPoint value) + { + if (!valid_ || !pointValid(value)) return valid_ = false; + if (!coordinate(value) || !text(" ")) return false; + ++count_; + return true; + } + bool finish() + { + if (!valid_ || count_ < 2) return valid_ = false; + const bool complete = text("") && + endpoint("destination", destination_name_, destination_) && text("") && flush(); + valid_ = false; + return complete; + } + size_t pointCount() const { return count_; } + + private: + static bool pointValid(tmap::GeoPoint value) + { + return value.latitude >= -900000000 && value.latitude <= 900000000 && + value.longitude >= -1800000000 && value.longitude <= 1800000000; + } + bool flush() + { + if (!valid_) return false; + if (used_ && !write_(context_, bytes_, used_)) return valid_ = false; + used_ = 0; + return true; + } + bool text(const char* value) + { + if (!valid_ || !value) return false; + while (*value) + { + if (used_ == sizeof(bytes_) && !flush()) return false; + bytes_[used_++] = *value++; + } + return true; + } + bool escaped(const char* value) + { + for (; *value; ++value) + { + const char* replacement = nullptr; + switch (*value) + { + case '&': + replacement = "&"; + break; + case '<': + replacement = "<"; + break; + case '>': + replacement = ">"; + break; + case '"': + replacement = """; + break; + case '\'': + replacement = "'"; + break; + default: + break; + } + if (replacement) + { + if (!text(replacement)) return false; + } + else + { + if (used_ == sizeof(bytes_) && !flush()) return false; + bytes_[used_++] = *value; + } + } + return valid_; + } + bool coordinate(tmap::GeoPoint value) + { + const auto latitude = value.latitude < 0 ? -int64_t(value.latitude) : int64_t(value.latitude); + const auto longitude = value.longitude < 0 ? -int64_t(value.longitude) : int64_t(value.longitude); + std::snprintf(coordinate_, sizeof(coordinate_), "%s%lu.%07lu,%s%lu.%07lu", + value.longitude < 0 ? "-" : "", static_cast(longitude / 10000000), + static_cast(longitude % 10000000), value.latitude < 0 ? "-" : "", + static_cast(latitude / 10000000), static_cast(latitude % 10000000)); + return text(coordinate_); + } + bool endpoint(const char* role, const char* name, tmap::GeoPoint value) + { + return text("") && escaped(name) && text("") && + text(role) && text("") && coordinate(value) && + text(""); + } + Write write_ = nullptr; + void* context_ = nullptr; + const char* destination_name_ = nullptr; + tmap::GeoPoint destination_{}; + size_t used_ = 0, count_ = 0; + char bytes_[192]{}, coordinate_[64]{}; + bool valid_ = false; +}; +static_assert(sizeof(RouteKmlWriter) <= 320, "Navigation serialization has one small output buffer"); +} // namespace navigation diff --git a/modules/ui_shared/include/ui/screens/tracker/tracker_navigation_dialog.h b/modules/ui_shared/include/ui/screens/tracker/tracker_navigation_dialog.h new file mode 100644 index 00000000..7afe66b0 --- /dev/null +++ b/modules/ui_shared/include/ui/screens/tracker/tracker_navigation_dialog.h @@ -0,0 +1,10 @@ +#pragma once +#include "lvgl.h" + +namespace tracker::ui::navigation_dialog +{ +using Completed = void (*)(const char* path, void* context); +bool open(lv_obj_t* parent, Completed completed, void* context = nullptr); +bool is_open(); +void close(); +} // namespace tracker::ui::navigation_dialog diff --git a/modules/ui_shared/include/ui/screens/tracker/tracker_state.h b/modules/ui_shared/include/ui/screens/tracker/tracker_state.h index baba055e..a2d16605 100644 --- a/modules/ui_shared/include/ui/screens/tracker/tracker_state.h +++ b/modules/ui_shared/include/ui/screens/tracker/tracker_state.h @@ -19,7 +19,8 @@ struct TrackerPageState enum class Mode : uint8_t { Record = 0, - Route = 1 + Route = 1, + Navigation = 2 }; enum class FocusColumn : uint8_t { @@ -49,6 +50,8 @@ struct TrackerPageState lv_obj_t* mode_record_label = nullptr; lv_obj_t* mode_route_btn = nullptr; lv_obj_t* mode_route_label = nullptr; + lv_obj_t* mode_navigation_btn = nullptr; + lv_obj_t* mode_navigation_label = nullptr; lv_obj_t* status_label = nullptr; diff --git a/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp b/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp index 2d2c220d..ec99acea 100644 --- a/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp @@ -11,6 +11,7 @@ using Projection = gps::ui::shell::Projection; #include "gps/kml/image_sources.h" #include "platform/ui/device_runtime.h" #include "platform/ui/gps_runtime.h" +#include "platform/ui/navigation_runtime.h" #include "platform/ui/route_storage.h" #include "platform/ui/team_ui_store_runtime.h" #include "platform/ui/tracker_runtime.h" @@ -177,6 +178,9 @@ enum class MapControlAction : uint8_t PickLocation, CancelLocation, Search, + Menu, + Navigation, + Team, }; const Host* s_host = nullptr; @@ -218,6 +222,13 @@ lv_obj_t* s_map_layer_btn = nullptr; lv_obj_t* s_map_contour_btn = nullptr; lv_obj_t* s_map_help_btn = nullptr; lv_obj_t* s_map_tracker_btn = nullptr; +lv_obj_t* s_map_menu_btn = nullptr; +lv_obj_t* s_map_menu = nullptr; +lv_obj_t* s_map_navigation_btn = nullptr; +bool s_select_navigation = false; +bool s_team_panel_visible = false; +lv_obj_t* s_map_menu_panel = nullptr; +lv_obj_t* s_map_team_btn = nullptr; lv_obj_t* s_map_altitude_panel = nullptr; lv_obj_t* s_map_altitude_label = nullptr; ::ui::widgets::route_elevation_profile::Widget s_route_elevation_profile; @@ -267,7 +278,11 @@ uint32_t s_member_panel_last_ms = 0; void refresh_view(); void root_key_event_cb(lv_event_t* e); void open_map_help_modal(); -void open_tracker_modal(); +void open_tracker_modal(bool navigation = false); +void close_map_menu(); +void open_map_menu(bool team = false); +void refresh_map_menu_labels(); +void on_map_control_clicked(lv_event_t* event); void add_map_controls_to_group(lv_group_t* group); void request_refresh_view(); void consume_key_event(lv_event_t* e); @@ -650,6 +665,11 @@ void clear_map_controls() s_map_contour_btn = nullptr; s_map_help_btn = nullptr; s_map_tracker_btn = nullptr; + s_map_menu_btn = s_map_menu = s_map_navigation_btn = nullptr; + s_select_navigation = false; + s_team_panel_visible = false; + s_map_menu_panel = nullptr; + s_map_team_btn = nullptr; s_map_altitude_panel = nullptr; s_map_altitude_label = nullptr; ::ui::widgets::route_elevation_profile::reset(s_route_elevation_profile); @@ -1683,21 +1703,24 @@ void member_button_event_cb(lv_event_t* e) const uint32_t member_id = static_cast( reinterpret_cast(lv_event_get_user_data(e))); select_member(member_id); + close_map_menu(); } -lv_obj_t* create_member_button(const ::team::ui::TeamMemberUi& member) +lv_obj_t* create_member_button(const ::team::ui::TeamMemberUi& member, lv_obj_t* parent = nullptr) { - if (!s_map_context_rail || !lv_obj_is_valid(s_map_context_rail)) + if (!parent) parent = s_map_context_rail; + if (!parent || !lv_obj_is_valid(parent)) { return nullptr; } const uint32_t member_id = member_id_for_button(member); - lv_obj_t* btn = create_map_control_button(s_map_context_rail, + lv_obj_t* btn = create_map_control_button(parent, kMapSideRailWidth - 10, "", MapControlAction::TeamMember); lv_obj_set_height(btn, kMapControlButtonHeight); + if (parent != s_map_context_rail) lv_obj_remove_event_cb(btn, on_map_control_clicked); lv_obj_set_style_pad_left(btn, 3, LV_PART_MAIN); lv_obj_set_style_pad_right(btn, 3, LV_PART_MAIN); lv_obj_add_event_cb(btn, @@ -1837,58 +1860,17 @@ void sync_map_context_buttons(const ::ui::map::MapWorkspaceSnapshot& snapshot) } const bool show_route = route_context_available(); - ::team::ui::TeamUiSnapshot team_snapshot; - const bool has_team_members = load_team_snapshot(team_snapshot) && !team_snapshot.members.empty(); - const uint8_t next_mask = static_cast((show_route ? 0x01 : 0x00) | - (has_team_members ? 0x02 : 0x00)); - const uint32_t now_ms = sys::millis_now(); + const uint8_t next_mask = show_route ? 0x01 : 0x00; bool group_dirty = next_mask != s_map_context_mask; set_hidden(s_map_route_btn, !show_route); set_hidden(s_map_context_rail, next_mask == 0); - if (has_team_members) - { - const uint32_t hash = hash_member_list(team_snapshot); - const bool refresh_due = (now_ms - s_member_panel_last_ms) >= 2000U; - if (hash != s_member_list_hash || - team_snapshot.members.size() != s_member_buttons.size() || - refresh_due) - { - s_member_panel_last_ms = now_ms; - if (hash != s_member_list_hash || - team_snapshot.members.size() != s_member_buttons.size()) - { - clear_member_buttons(); - s_member_buttons.reserve(team_snapshot.members.size()); - s_member_button_ids.reserve(team_snapshot.members.size()); - for (const auto& member : team_snapshot.members) - { - const uint32_t member_id = member_id_for_button(member); - lv_obj_t* btn = create_member_button(member); - if (!btn) - { - continue; - } - s_member_buttons.push_back(btn); - s_member_button_ids.push_back(member_id); - } - s_member_list_hash = hash; - group_dirty = true; - } - } - - if (!member_exists(team_snapshot, s_selected_member_id)) - { - s_selected_member_id = kInvalidMemberId; - } - update_member_button_states(); - } - else if (!s_member_buttons.empty() || s_member_list_hash != 0) + // Team controls are built only when its taskbar menu is opened. + if (!s_member_buttons.empty() || s_member_list_hash != 0) { clear_member_buttons(); s_member_list_hash = 0; - s_selected_member_id = kInvalidMemberId; group_dirty = true; } @@ -1930,8 +1912,8 @@ void sync_map_control_labels(const ::ui::map::MapWorkspaceSnapshot& snapshot) sync_map_altitude_overlay(); const auto layers = ::ui::widgets::map::current_layer_state(); - set_button_label(s_map_layer_btn, compact_map_source_label(layers.map_source)); - set_button_label(s_map_contour_btn, layers.contour_enabled ? "Ctr*" : "Ctr"); + (void)layers; + refresh_map_menu_labels(); sync_map_context_buttons(snapshot); char zoom_buf[8]{}; @@ -3534,9 +3516,9 @@ void close_tracker_modal() lv_obj_del(s_tracker_modal); s_tracker_modal = nullptr; rebuild_map_control_group(); - if (app_g && s_map_tracker_btn && lv_obj_is_valid(s_map_tracker_btn)) + if (app_g && s_map_menu_btn && lv_obj_is_valid(s_map_menu_btn)) { - lv_group_focus_obj(s_map_tracker_btn); + lv_group_focus_obj(s_map_menu_btn); } } @@ -3589,8 +3571,17 @@ void tracker_track_button_event_cb(lv_event_t* e) } const std::string path = - std::string(::platform::ui::tracker::track_dir()) + "/" + s_track_modal_names[index]; - (void)load_map_track_file_impl(path.c_str(), true); + std::string(s_select_navigation ? ::platform::ui::navigation::directory() : ::platform::ui::tracker::track_dir()) + "/" + s_track_modal_names[index]; + if (load_map_track_file_impl(path.c_str(), true) && s_select_navigation) + { + auto edit = app::appFacade().beginConfigEdit(); + if (edit && path.size() < sizeof(edit.config().route_path)) + { + edit.config().route_enabled = true; + std::strcpy(edit.config().route_path, path.c_str()); + edit.commit(app::AppConfigChangeSet::route()); + } + } close_tracker_modal(); } @@ -3624,7 +3615,7 @@ lv_obj_t* create_tracker_modal_button(lv_obj_t* parent, return btn; } -void open_tracker_modal() +void open_tracker_modal(bool navigation) { if (s_tracker_modal && lv_obj_is_valid(s_tracker_modal)) { @@ -3641,6 +3632,7 @@ void open_tracker_modal() request_refresh_view(); return; } + s_select_navigation = navigation; s_tracker_modal = lv_obj_create(s_root); lv_obj_set_size(s_tracker_modal, LV_PCT(100), LV_PCT(100)); @@ -3674,7 +3666,7 @@ void open_tracker_modal() lv_obj_add_event_cb(panel, tracker_modal_key_event_cb, LV_EVENT_KEY, nullptr); lv_obj_t* title = lv_label_create(panel); - lv_label_set_text(title, "Select Track"); + ::ui::i18n::set_label_text(title, navigation ? "Load navigation" : "Load track"); lv_obj_set_width(title, LV_PCT(100)); lv_obj_set_style_text_font(title, &lv_font_montserrat_12, LV_PART_MAIN); lv_obj_set_style_text_color(title, lv_color_hex(0x25170D), LV_PART_MAIN); @@ -3691,7 +3683,8 @@ void open_tracker_modal() lv_obj_set_scrollbar_mode(list, LV_SCROLLBAR_MODE_AUTO); s_track_modal_names.clear(); - ::platform::ui::tracker::list_tracks(s_track_modal_names, 64); + if (navigation) ::platform::ui::navigation::list(s_track_modal_names, 64); + else ::platform::ui::tracker::list_tracks(s_track_modal_names, 64); std::sort(s_track_modal_names.begin(), s_track_modal_names.end()); lv_obj_t* first_focus = nullptr; @@ -3699,7 +3692,7 @@ void open_tracker_modal() if (s_track_modal_names.empty()) { lv_obj_t* empty = lv_label_create(list); - lv_label_set_text(empty, "No track files"); + ::ui::i18n::set_label_text(empty, navigation ? "No saved navigation" : "No track files"); lv_obj_set_width(empty, LV_PCT(100)); lv_obj_set_style_text_align(empty, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); lv_obj_set_style_text_font(empty, &lv_font_montserrat_12, LV_PART_MAIN); @@ -3822,6 +3815,15 @@ void on_map_control_clicked(lv_event_t* e) const auto action = static_cast( reinterpret_cast(lv_event_get_user_data(e))); + if (action == MapControlAction::Menu || action == MapControlAction::Team) + { + const bool team = action == MapControlAction::Team; + const bool was_same = s_map_menu && s_team_panel_visible == team; + close_map_menu(); + if (!was_same) open_map_menu(team); + return; + } + close_map_menu(); switch (action) { case MapControlAction::ZoomOut: @@ -3854,6 +3856,13 @@ void on_map_control_clicked(lv_event_t* e) case MapControlAction::Tracker: open_tracker_modal(); break; + case MapControlAction::Navigation: + open_tracker_modal(true); + break; + case MapControlAction::Team: + break; + case MapControlAction::Menu: + break; case MapControlAction::Help: request_open_map_help_modal(); break; @@ -3879,6 +3888,22 @@ void on_map_control_clicked(lv_event_t* e) bool handle_map_key(uint32_t key, lv_event_t* e) { + if (s_map_menu) + { + if (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE || key == 'm' || key == 'M') + { + consume_key_event(e); + close_map_menu(); + return true; + } + return false; + } + if (!s_location_request && (key == 'm' || key == 'M')) + { + consume_key_event(e); + open_map_menu(); + return true; + } if (!s_location_request && (key == 'f' || key == 'F')) { const auto viewport = map_workspace_model().viewport(); @@ -4089,6 +4114,18 @@ void add_map_controls_to_group(lv_group_t* group) { return; } + if (s_map_menu) + { + if (s_map_menu_panel) + for (uint32_t index = 0; index < lv_obj_get_child_count(s_map_menu_panel); ++index) + { + auto* child = lv_obj_get_child(s_map_menu_panel, index); + if (lv_obj_has_flag(child, LV_OBJ_FLAG_CLICKABLE)) lv_group_add_obj(group, child); + } + if (s_map_team_btn) lv_group_add_obj(group, s_map_team_btn); + if (s_map_menu_btn) lv_group_add_obj(group, s_map_menu_btn); + return; + } if (s_map_pick_btn) lv_group_add_obj(group, s_map_pick_btn); if (s_map_cancel_btn) lv_group_add_obj(group, s_map_cancel_btn); if (s_map_zoom_out_btn) lv_group_add_obj(group, s_map_zoom_out_btn); @@ -4099,6 +4136,9 @@ void add_map_controls_to_group(lv_group_t* group) if (s_map_contour_btn) lv_group_add_obj(group, s_map_contour_btn); if (s_map_tracker_btn) lv_group_add_obj(group, s_map_tracker_btn); if (s_map_help_btn) lv_group_add_obj(group, s_map_help_btn); + if (s_map_team_btn) lv_group_add_obj(group, s_map_team_btn); + if (s_map_menu_btn) lv_group_add_obj(group, s_map_menu_btn); + if (s_map_navigation_btn) lv_group_add_obj(group, s_map_navigation_btn); if (map_control_visible(s_map_route_btn)) lv_group_add_obj(group, s_map_route_btn); for (lv_obj_t* member_btn : s_member_buttons) { @@ -4109,8 +4149,114 @@ void add_map_controls_to_group(lv_group_t* group) } } +void close_map_menu() +{ + if (!s_map_menu) return; + auto* menu = s_map_menu; + s_map_menu = nullptr; + const bool team = s_team_panel_visible; + s_team_panel_visible = false; + s_map_menu_panel = nullptr; + s_map_search_btn = s_map_layer_btn = s_map_contour_btn = s_map_tracker_btn = s_map_navigation_btn = nullptr; + if (lv_obj_is_valid(menu)) lv_obj_del(menu); + rebuild_map_control_group(); + if (app_g && map_control_visible(team ? s_map_team_btn : s_map_menu_btn)) lv_group_focus_obj(team ? s_map_team_btn : s_map_menu_btn); +} + +void refresh_map_menu_labels() +{ + if (!s_map_menu) return; + const auto layers = ::ui::widgets::map::current_layer_state(); + const char* source = layers.map_source == 1 ? "Terrain" : layers.map_source == 2 ? "Satellite" + : "OSM"; + const auto label = ::ui::i18n::format("Map layer: %s", ::ui::i18n::tr(source)); + set_button_label(s_map_layer_btn, label.c_str()); + set_button_label(s_map_contour_btn, ::ui::i18n::tr(layers.contour_enabled ? "Hide contours" : "Show contours")); +} + +void open_map_menu(bool team) +{ + if (s_map_menu || s_location_request || !s_map_viewport || !lv_obj_is_valid(s_map_viewport)) return; + s_map_menu = lv_obj_create(s_map_viewport); + s_team_panel_visible = team; + lv_obj_set_size(s_map_menu, LV_PCT(100), LV_PCT(100)); + lv_obj_add_flag(s_map_menu, LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_obj_set_style_pad_all(s_map_menu, 0, 0); + lv_obj_set_style_border_width(s_map_menu, 0, 0); + lv_obj_set_style_radius(s_map_menu, 0, 0); + lv_obj_set_style_bg_opa(s_map_menu, LV_OPA_TRANSP, 0); + lv_obj_clear_flag(s_map_menu, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb( + s_map_menu, [](lv_event_t* event) + { if (lv_event_get_target(event) == s_map_menu) close_map_menu(); }, + LV_EVENT_CLICKED, nullptr); + auto* panel = s_map_menu_panel = lv_obj_create(s_map_menu); + const auto width = std::min(228, lv_obj_get_width(s_map_viewport) - 8); + const auto available = std::max(48, lv_obj_get_height(s_map_viewport) - kMapControlBarHeight - 8); + lv_obj_set_size(panel, width, LV_SIZE_CONTENT); + lv_obj_set_style_max_height(panel, available, 0); + const lv_coord_t right = team ? std::min(kMapControlButtonWideWidth + 6, std::max(3, lv_obj_get_width(s_map_viewport) - width - 3)) : 3; + lv_obj_align(panel, LV_ALIGN_BOTTOM_RIGHT, -right, -(kMapControlBarHeight + 3)); + lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(panel, 3, 0); + lv_obj_set_style_pad_row(panel, 2, 0); + lv_obj_set_style_bg_color(panel, lv_color_hex(0xFFF3DF), 0); + lv_obj_set_style_border_color(panel, lv_color_hex(0x8A6E43), 0); + lv_obj_set_style_border_width(panel, 1, 0); + lv_obj_set_style_radius(panel, 2, 0); + lv_obj_set_style_shadow_width(panel, 8, 0); + lv_obj_set_scroll_dir(panel, LV_DIR_VER); + lv_obj_set_scrollbar_mode(panel, LV_SCROLLBAR_MODE_AUTO); + auto item = [&](const char* text, MapControlAction action) + { + auto* button = create_map_control_button(panel, width - 10, ::ui::i18n::tr(text), action); + lv_obj_set_height(button, ::ui::page_profile::current().large_touch_hitbox ? 44 : 28); + lv_obj_set_style_text_align(lv_obj_get_child(button, 0), LV_TEXT_ALIGN_LEFT, 0); + return button; + }; + lv_obj_t* first = nullptr; + if (team) + { + ::team::ui::TeamUiSnapshot snapshot; + if (load_team_snapshot(snapshot)) + for (const auto& member : snapshot.members) + { + auto* button = create_member_button(member, panel); + if (!button) continue; + lv_obj_set_width(button, width - 10); + lv_obj_set_height(button, ::ui::page_profile::current().large_touch_hitbox ? 44 : 28); + style_member_button_selected(button, member_id_for_button(member) == s_selected_member_id); + if (!first) first = button; + } + if (!first) + { + auto* empty = lv_label_create(panel); + ::ui::i18n::set_label_text(empty, "No team"); + } + } + else + { + s_map_contour_btn = item("Show contours", MapControlAction::Contour); + s_map_layer_btn = item("Map layer: %s", MapControlAction::Layer); + s_map_search_btn = item("Search POI", MapControlAction::Search); + s_map_navigation_btn = item("Load navigation", MapControlAction::Navigation); + s_map_tracker_btn = item("Load track", MapControlAction::Tracker); + first = s_map_contour_btn; + } + refresh_map_menu_labels(); + // Keep the taskbar above the transparent dismiss surface so Menu can + // toggle the popup and Pos/Help remain directly accessible. + lv_obj_move_foreground(s_map_control_bar); + rebuild_map_control_group(); + if (app_g && first) lv_group_focus_obj(first); +} + void create_map_control_bar(lv_obj_t* viewport) { + const bool narrow = lv_obj_get_width(viewport) < 300; + const lv_coord_t small = narrow ? 20 : kMapControlButtonSmallWidth; + const lv_coord_t medium = narrow ? 28 : kMapControlButtonMediumWidth; + const lv_coord_t wide = narrow ? 34 : kMapControlButtonWideWidth; s_map_control_bar = lv_obj_create(viewport); lv_obj_set_size(s_map_control_bar, LV_PCT(100), kMapControlBarHeight); lv_obj_align(s_map_control_bar, LV_ALIGN_BOTTOM_MID, 0, 0); @@ -4129,20 +4275,18 @@ void create_map_control_bar(lv_obj_t* viewport) lv_obj_set_style_pad_top(s_map_control_bar, 2, 0); lv_obj_set_style_pad_bottom(s_map_control_bar, 2, 0); lv_obj_set_style_pad_column(s_map_control_bar, 3, 0); - // Fixed controls fit a 320px screen; route/team controls may extend it. - lv_obj_add_flag(s_map_control_bar, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_scroll_dir(s_map_control_bar, LV_DIR_HOR); + lv_obj_clear_flag(s_map_control_bar, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_scrollbar_mode(s_map_control_bar, LV_SCROLLBAR_MODE_OFF); bind_map_key_handler(s_map_control_bar); s_map_zoom_out_btn = create_map_control_button( s_map_control_bar, - kMapControlButtonSmallWidth, + small, "-", MapControlAction::ZoomOut); s_map_zoom_label = lv_label_create(s_map_control_bar); lv_label_set_text(s_map_zoom_label, "Z7"); - lv_obj_set_width(s_map_zoom_label, 24); + lv_obj_set_width(s_map_zoom_label, narrow ? 20 : 24); lv_obj_set_style_text_font(s_map_zoom_label, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(s_map_zoom_label, lv_color_hex(0x3E2B18), 0); lv_obj_set_style_text_align(s_map_zoom_label, LV_TEXT_ALIGN_CENTER, 0); @@ -4150,43 +4294,36 @@ void create_map_control_bar(lv_obj_t* viewport) s_map_zoom_in_btn = create_map_control_button( s_map_control_bar, - kMapControlButtonSmallWidth, + small, "+", MapControlAction::ZoomIn); + auto* spacer = lv_obj_create(s_map_control_bar); + lv_obj_remove_style_all(spacer); + lv_obj_set_size(spacer, 0, 1); + lv_obj_set_flex_grow(spacer, 1); + lv_obj_clear_flag(spacer, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(spacer, LV_OBJ_FLAG_SCROLLABLE); s_map_center_btn = create_map_control_button( s_map_control_bar, - kMapControlButtonMediumWidth, + medium, "Pos", MapControlAction::Center); if (s_location_request) { - s_map_pick_btn = create_map_control_button(s_map_control_bar, 66, ::ui::i18n::tr("Pick"), MapControlAction::PickLocation); - s_map_cancel_btn = create_map_control_button(s_map_control_bar, 66, ::ui::i18n::tr("Cancel"), MapControlAction::CancelLocation); + s_map_pick_btn = create_map_control_button(s_map_control_bar, narrow ? 54 : 66, ::ui::i18n::tr("Pick"), MapControlAction::PickLocation); + s_map_cancel_btn = create_map_control_button(s_map_control_bar, narrow ? 54 : 66, ::ui::i18n::tr("Cancel"), MapControlAction::CancelLocation); lv_obj_move_foreground(s_map_control_bar); return; } - s_map_layer_btn = create_map_control_button( - s_map_control_bar, - kMapControlButtonMediumWidth, - "OSM", - MapControlAction::Layer); - s_map_search_btn = create_map_control_button( - s_map_control_bar, kMapControlButtonSmallWidth, "F", MapControlAction::Search); - s_map_contour_btn = create_map_control_button( - s_map_control_bar, - kMapControlButtonContourWidth, - "Ctr", - MapControlAction::Contour); - s_map_tracker_btn = create_map_control_button( - s_map_control_bar, - tracker_button_width(), - tracker_button_label(), - MapControlAction::Tracker); s_map_help_btn = create_map_control_button( s_map_control_bar, - kMapControlButtonSmallWidth, - help_key_label(), + wide, + ::ui::i18n::tr("Help"), MapControlAction::Help); + s_map_team_btn = create_map_control_button( + s_map_control_bar, wide, ::ui::i18n::tr("Team"), MapControlAction::Team); + s_map_menu_btn = create_map_control_button( + s_map_control_bar, wide, ::ui::i18n::tr("Menu"), MapControlAction::Menu); lv_obj_move_foreground(s_map_control_bar); } diff --git a/modules/ui_shared/src/ui/screens/tracker/tracker_navigation_dialog.cpp b/modules/ui_shared/src/ui/screens/tracker/tracker_navigation_dialog.cpp new file mode 100644 index 00000000..1c7defc2 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/tracker/tracker_navigation_dialog.cpp @@ -0,0 +1,283 @@ +#include "ui/screens/tracker/tracker_navigation_dialog.h" +#include "platform/memory/psram_ptr.h" +#include "platform/ui/gps_runtime.h" +#include "platform/ui/navigation_runtime.h" +#include "ui/app_runtime.h" +#include "ui/components/map_poi_search.h" +#include "ui/components/two_pane_styles.h" +#include "ui/localization.h" +#include "ui/page/page_profile.h" +#include +#include +#include +#include + +namespace tracker::ui::navigation_dialog +{ +namespace +{ +namespace nav = platform::ui::navigation; +struct State +{ + nav::Request request{}; + nav::Snapshot snapshot{}; + lv_obj_t* root = nullptr; + lv_obj_t* origin = nullptr; + lv_obj_t* destination = nullptr; + lv_obj_t* status = nullptr; + lv_obj_t* calculate = nullptr; + lv_obj_t* save = nullptr; + lv_group_t* group = nullptr; + lv_group_t* restore = nullptr; + lv_timer_t* timer = nullptr; + Completed completed = nullptr; + void* context = nullptr; + bool current_origin = true; + bool destination_set = false; + bool choosing_origin = false; + bool calculating = false; +}; +State* s_state = nullptr; +bool activate(lv_event_t* event) +{ + if (lv_event_get_code(event) == LV_EVENT_CLICKED) return true; + if (lv_event_get_code(event) != LV_EVENT_KEY || lv_event_get_key(event) != LV_KEY_ENTER) return false; + lv_event_stop_bubbling(event); + lv_event_stop_processing(event); + return true; +} +void message(const char* key) +{ + if (s_state) ::ui::i18n::set_label_text(s_state->status, key); +} +void selected(const platform::ui::map_search::Result& result, void*) +{ + if (!s_state) return; + auto& state = *s_state; + auto& endpoint = state.choosing_origin ? state.request.origin : state.request.destination; + endpoint.latitude_e7 = result.latitude_e7; + endpoint.longitude_e7 = result.longitude_e7; + std::memcpy(endpoint.name, result.name, sizeof(endpoint.name)); + endpoint.name[sizeof(endpoint.name) - 1] = 0; + auto* label = lv_obj_get_child(state.choosing_origin ? state.origin : state.destination, 0); + ::ui::i18n::set_content_label_text_raw(label, endpoint.name); + if (state.choosing_origin) state.current_origin = false; + else state.destination_set = true; + lv_obj_add_state(state.save, LV_STATE_DISABLED); +} +void search(lv_event_t* event) +{ + if (!s_state || s_state->calculating || !activate(event)) return; + auto& state = *s_state; + state.choosing_origin = reinterpret_cast(lv_event_get_user_data(event)) == 0; + const auto fix = platform::ui::gps::get_data(); + const double latitude = fix.valid ? fix.lat : state.request.origin.latitude_e7 / 1e7; + const double longitude = fix.valid ? fix.lng : state.request.origin.longitude_e7 / 1e7; + if (!::ui::components::map_poi_search::open(state.root, latitude, longitude, selected)) message("Search unavailable"); +} +void current(lv_event_t* event) +{ + if (!s_state || s_state->calculating || !activate(event)) return; + s_state->current_origin = true; + ::ui::i18n::set_label_text(lv_obj_get_child(s_state->origin, 0), "Current location"); + lv_obj_add_state(s_state->save, LV_STATE_DISABLED); +} +void calculate(lv_event_t* event) +{ + if (!s_state || s_state->calculating || !activate(event)) return; + auto& state = *s_state; + if (!state.destination_set) + { + message("Choose destination"); + return; + } + if (state.current_origin) + { + const auto fix = platform::ui::gps::get_data(); + if (!fix.valid || fix.age > 5000 || !std::isfinite(fix.lat) || !std::isfinite(fix.lng) || std::abs(fix.lat) > 90 || std::abs(fix.lng) > 180) + { + message("No GPS fix"); + return; + } + state.request.origin.latitude_e7 = static_cast(std::llround(fix.lat * 1e7)); + state.request.origin.longitude_e7 = static_cast(std::llround(fix.lng * 1e7)); + std::snprintf(state.request.origin.name, sizeof(state.request.origin.name), "%s", ::ui::i18n::tr("Current location")); + } + nav::cancel(); + state.snapshot = {}; + if (!nav::submit(state.request)) + { + message("Navigation unavailable"); + return; + } + state.calculating = true; + lv_obj_add_state(state.calculate, LV_STATE_DISABLED); + lv_obj_add_state(state.save, LV_STATE_DISABLED); + message("Calculating route..."); +} +void poll(lv_timer_t*) +{ + if (!s_state || !nav::poll(s_state->snapshot)) return; + auto& state = *s_state; + if (state.snapshot.status == nav::Status::Calculating) return; + state.calculating = false; + lv_obj_clear_state(state.calculate, LV_STATE_DISABLED); + const char* key = "Navigation unavailable"; + switch (state.snapshot.status) + { + case nav::Status::Ready: + { + const auto text = ::ui::i18n::format("Route ready: %.1f km", state.snapshot.distance_m / 1000.0); + ::ui::i18n::set_label_text_raw(state.status, text.c_str()); + lv_obj_clear_state(state.save, LV_STATE_DISABLED); + return; + } + case nav::Status::NoRoutingData: + key = "No TMAP routing data"; + break; + case nav::Status::NoPath: + key = "No path found"; + break; + case nav::Status::OutsideCoverage: + key = "Outside routing coverage"; + break; + case nav::Status::MemoryUnavailable: + key = "Navigation memory limit"; + break; + case nav::Status::StorageUnavailable: + key = "Route storage unavailable"; + break; + case nav::Status::Cancelled: + key = "Cancelled"; + break; + default: + break; + } + message(key); +} +void save(lv_event_t* event) +{ + if (!s_state || s_state->calculating || !activate(event)) return; + if (!nav::save()) + { + message("Save failed"); + return; + } + nav::poll(s_state->snapshot); + const std::string path = s_state->snapshot.path; + const auto completed = s_state->completed; + void* context = s_state->context; + close(); + if (completed) completed(path.c_str(), context); +} +void key(lv_event_t* event) +{ + if (lv_event_get_code(event) == LV_EVENT_KEY && (lv_event_get_key(event) == LV_KEY_ESC || lv_event_get_key(event) == LV_KEY_BACKSPACE)) + { + lv_event_stop_bubbling(event); + lv_event_stop_processing(event); + close(); + } +} +lv_obj_t* button(lv_obj_t* parent, const char* text, lv_event_cb_t callback, uintptr_t data = 0) +{ + auto* object = lv_btn_create(parent); + ::ui::components::two_pane_styles::apply_btn_basic(object); + lv_obj_set_size(object, LV_PCT(100), ::ui::page_profile::current().large_touch_hitbox ? 44 : 32); + auto* label = lv_label_create(object); + lv_obj_set_width(label, LV_PCT(100)); + lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); + ::ui::i18n::set_label_text(label, text); + lv_obj_center(label); + lv_obj_add_event_cb(object, callback, LV_EVENT_CLICKED, reinterpret_cast(data)); + lv_obj_add_event_cb(object, callback, LV_EVENT_KEY, reinterpret_cast(data)); + lv_obj_add_event_cb(object, key, LV_EVENT_KEY, nullptr); + lv_group_add_obj(s_state->group, object); + return object; +} +} // namespace +bool is_open() { return s_state != nullptr; } +bool open(lv_obj_t* parent, Completed completed, void* context) +{ + if (s_state || !parent || !nav::supported()) return false; + s_state = ::platform::memory::createPsram(); + if (!s_state) return false; + auto& state = *s_state; + state.completed = completed; + state.context = context; + state.restore = lv_group_get_default(); + state.group = lv_group_create(); + if (!state.group) + { + close(); + return false; + } + ::set_default_group(state.group); + state.root = lv_obj_create(parent); + lv_obj_add_flag(state.root, LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_obj_set_size(state.root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(state.root, lv_color_hex(0xFFF8EE), 0); + lv_obj_set_style_bg_opa(state.root, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(state.root, 8, 0); + lv_obj_set_flex_flow(state.root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(state.root, 4, 0); + lv_obj_clear_flag(state.root, LV_OBJ_FLAG_SCROLLABLE); + auto* header = lv_obj_create(state.root); + lv_obj_remove_style_all(header); + lv_obj_set_size(header, LV_PCT(100), ::ui::page_profile::current().large_touch_hitbox ? 44 : 32); + lv_obj_set_flex_flow(header, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(header, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_clear_flag(header, LV_OBJ_FLAG_SCROLLABLE); + auto* title = lv_label_create(header); + lv_obj_set_width(title, 0); + lv_obj_set_flex_grow(title, 1); + lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); + ::ui::i18n::set_label_text(title, "New navigation"); + auto* cancel = button(header, "Cancel", [](lv_event_t* event) + { if (activate(event)) close(); }); + lv_obj_set_width(cancel, 84); + auto* body = lv_obj_create(state.root); + lv_obj_remove_style_all(body); + lv_obj_set_size(body, LV_PCT(100), 0); + lv_obj_set_flex_grow(body, 1); + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(body, 4, 0); + lv_obj_set_scroll_dir(body, LV_DIR_VER); + lv_obj_add_flag(body, LV_OBJ_FLAG_SCROLLABLE); + auto* origin_title = lv_label_create(body); + ::ui::i18n::set_label_text(origin_title, "Origin"); + state.origin = button(body, "Current location", search, 0); + button(body, "Use current location", current); + auto* origin_hint = lv_label_create(body); + lv_obj_set_width(origin_hint, LV_PCT(100)); + lv_label_set_long_mode(origin_hint, LV_LABEL_LONG_WRAP); + ::ui::i18n::set_label_text(origin_hint, "Tap the origin to search for a place"); + auto* destination_title = lv_label_create(body); + ::ui::i18n::set_label_text(destination_title, "Destination"); + state.destination = button(body, "Choose destination", search, 1); + state.status = lv_label_create(body); + lv_obj_set_width(state.status, LV_PCT(100)); + lv_label_set_long_mode(state.status, LV_LABEL_LONG_WRAP); + state.calculate = button(body, "Calculate route", calculate); + state.save = button(body, "Save and preview", save); + lv_obj_add_state(state.save, LV_STATE_DISABLED); + state.timer = lv_timer_create(poll, 150, nullptr); + platform::ui::gps::acquire_power_lease("navigation"); + lv_group_focus_obj(state.destination); + return true; +} +void close() +{ + if (!s_state) return; + ::ui::components::map_poi_search::close(); + auto* state = s_state; + s_state = nullptr; + nav::cancel(); + platform::ui::gps::release_power_lease("navigation"); + if (state->timer) lv_timer_del(state->timer); + if (state->root && lv_obj_is_valid(state->root)) lv_obj_del(state->root); + if (state->restore) ::set_default_group(state->restore); + if (state->group) lv_group_del(state->group); + ::platform::memory::destroyPsram(state); +} +} // namespace tracker::ui::navigation_dialog diff --git a/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp b/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp index a44f0af4..047ee7d4 100644 --- a/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp @@ -3,6 +3,7 @@ #include "app/app_facade_access.h" #include "gps/kml/image_sources.h" #include "platform/ui/device_runtime.h" +#include "platform/ui/navigation_runtime.h" #include "platform/ui/route_storage.h" #include "platform/ui/tracker_runtime.h" #include "platform/ui/wifi_runtime.h" @@ -12,6 +13,7 @@ #include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/runtime/ui_feedback.h" +#include "ui/screens/tracker/tracker_navigation_dialog.h" #include "ui/screens/tracker/tracker_page_input.h" #include "ui/screens/tracker/tracker_page_layout.h" #include "ui/screens/tracker/tracker_state.h" @@ -175,6 +177,7 @@ lv_coord_t modal_button_width(); lv_coord_t action_menu_button_height(); lv_coord_t action_menu_row_gap(); void update_record_status(); +void refresh_route_list(); void update_start_stop_button(); void update_record_page(); void update_route_status(); @@ -229,6 +232,11 @@ lv_obj_t* create_list_item_button(const std::string& text, intptr_t user_data, b void on_back(void*) { + if (navigation_dialog::is_open()) + { + navigation_dialog::close(); + return; + } if (g_tracker_state.route_preview_help_modal) { close_route_preview_help_modal(); @@ -380,7 +388,7 @@ void modal_close(lv_obj_t*& modal_obj) bool is_any_modal_open() { - return g_tracker_state.del_confirm_modal != nullptr || + return navigation_dialog::is_open() || g_tracker_state.del_confirm_modal != nullptr || g_tracker_state.action_menu_modal != nullptr || g_tracker_state.route_preview_help_modal != nullptr; } @@ -490,7 +498,8 @@ void update_mode_buttons() auto& state = g_tracker_state; bool record_active = (state.mode == TrackerPageState::Mode::Record); style_mode_button(state.mode_record_btn, state.mode_record_label, record_active); - style_mode_button(state.mode_route_btn, state.mode_route_label, !record_active); + style_mode_button(state.mode_route_btn, state.mode_route_label, state.mode == TrackerPageState::Mode::Route); + style_mode_button(state.mode_navigation_btn, state.mode_navigation_label, state.mode == TrackerPageState::Mode::Navigation); } std::string path_basename(const std::string& path) @@ -544,7 +553,7 @@ bool can_delete_selected_item() bool can_load_selected_route() { auto& state = g_tracker_state; - return state.mode == TrackerPageState::Mode::Route && + return state.mode != TrackerPageState::Mode::Record && state.active_route.empty() && state.selected_route_idx >= 0 && !state.selected_route.empty(); @@ -553,14 +562,14 @@ bool can_load_selected_route() bool can_unload_active_route() { auto& state = g_tracker_state; - return state.mode == TrackerPageState::Mode::Route && + return state.mode != TrackerPageState::Mode::Record && !state.active_route.empty(); } bool can_preview_selected_route() { auto& state = g_tracker_state; - return state.mode == TrackerPageState::Mode::Route && + return state.mode != TrackerPageState::Mode::Record && state.selected_route_idx >= 0 && state.selected_route_idx < static_cast(s_route_names.size()) && !state.selected_route.empty(); @@ -568,7 +577,7 @@ bool can_preview_selected_route() std::string route_path_for_name(const std::string& route_name) { - return std::string(platform::ui::route_storage::route_dir()) + "/" + route_name; + return std::string(g_tracker_state.mode == TrackerPageState::Mode::Navigation ? platform::ui::navigation::directory() : platform::ui::route_storage::route_dir()) + "/" + route_name; } bool repair_degraded_active_route_from_list() @@ -2956,11 +2965,18 @@ void focus_main_panel() void set_mode(TrackerPageState::Mode mode) { auto& state = g_tracker_state; + const bool change_navigation = state.mode != mode && (mode == TrackerPageState::Mode::Navigation || state.mode == TrackerPageState::Mode::Navigation); + if (change_navigation) + { + state.selected_route.clear(); + state.selected_route_idx = -1; + } state.mode = mode; + if (change_navigation || mode == TrackerPageState::Mode::Navigation) refresh_route_list(); update_mode_buttons(); if (state.bottom_bar) { - if (mode == TrackerPageState::Mode::Record) + if (mode != TrackerPageState::Mode::Route) { lv_obj_clear_flag(state.bottom_bar, LV_OBJ_FLAG_HIDDEN); } @@ -2971,7 +2987,7 @@ void set_mode(TrackerPageState::Mode mode) } if (state.start_stop_btn) { - if (mode == TrackerPageState::Mode::Record) + if (mode != TrackerPageState::Mode::Route) { lv_obj_clear_flag(state.start_stop_btn, LV_OBJ_FLAG_HIDDEN); } @@ -2989,6 +3005,7 @@ void set_mode(TrackerPageState::Mode mode) } else { + update_start_stop_button(); update_route_status(); update_route_page(); } @@ -3020,9 +3037,10 @@ void update_start_stop_button() const bool recording = platform::ui::tracker::is_recording(); if (state.start_stop_label) { - ::ui::i18n::set_label_text(state.start_stop_label, recording ? "Stop recording" : "Start recording"); + ::ui::i18n::set_label_text(state.start_stop_label, state.mode == TrackerPageState::Mode::Navigation ? "New navigation" : recording ? "Stop recording" + : "Start recording"); int text_width = 0; - for (const char* key : {"Start recording", "Stop recording"}) + for (const char* key : {"Start recording", "Stop recording", "New navigation"}) { lv_point_t size; lv_text_get_size(&size, ::ui::i18n::tr(key), @@ -3268,7 +3286,8 @@ void update_item_card() std::string format = extension == std::string::npos ? "" : selected.substr(extension + 1); std::transform(format.begin(), format.end(), format.begin(), [](unsigned char ch) { return static_cast(std::toupper(ch)); }); - const char* status = ::ui::i18n::tr(recording ? "Tracks" : "Routes"); + const char* status = ::ui::i18n::tr(recording ? "Tracks" : state.mode == TrackerPageState::Mode::Navigation ? "Navigation" + : "Routes"); if (recording && platform::ui::tracker::is_recording()) { std::string current; @@ -3277,7 +3296,7 @@ void update_item_card() if (!recording && state.active_route == selected) ::ui::i18n::set_content_label_text_raw(state.card_format, ::ui::i18n::format("Active: %s", format.c_str()).c_str()); else lv_label_set_text_fmt(state.card_format, "%s | %s", format.c_str(), status); - const auto path = std::string(recording ? platform::ui::tracker::track_dir() : platform::ui::route_storage::route_dir()) + "/" + selected; + const auto path = recording ? std::string(platform::ui::tracker::track_dir()) + "/" + selected : route_path_for_name(selected); if (!state.card_location_model) state.card_location_model = ::platform::memory::createPsram<::ui::support::CardLocation>(); if (state.card_location_model && state.card_location_model->file != path) { @@ -3382,7 +3401,7 @@ void update_route_status() lv_obj_set_style_text_color(state.status_label, lv_color_hex(kPanelTextMuted), 0); lv_obj_set_style_text_font( state.status_label, ::ui::fonts::localized_font(::ui::fonts::ui_chrome_font()), 0); - if (state.mode == TrackerPageState::Mode::Route) + if (state.mode != TrackerPageState::Mode::Record) { if (!state.active_route.empty()) { @@ -3414,23 +3433,23 @@ void refresh_route_list() return; } s_route_names.clear(); - s_route_empty_text = ::ui::i18n::tr("No KML routes"); + s_route_empty_text = ::ui::i18n::tr(state.mode == TrackerPageState::Mode::Navigation ? "No saved navigation" : "No KML routes"); if (!platform::ui::device::sd_ready()) { s_route_empty_text = ::ui::i18n::tr("No SD Card"); - if (state.mode == TrackerPageState::Mode::Route) + if (state.mode != TrackerPageState::Mode::Record) { update_route_page(); } return; } - const bool route_dir_ready = platform::ui::route_storage::list_routes(s_route_names, 64); + const bool route_dir_ready = state.mode == TrackerPageState::Mode::Navigation ? platform::ui::navigation::list(s_route_names, 64) : platform::ui::route_storage::list_routes(s_route_names, 64); if (!route_dir_ready) { s_route_empty_text = ::ui::i18n::tr("No routes folder"); - if (state.mode == TrackerPageState::Mode::Route) + if (state.mode != TrackerPageState::Mode::Record) { update_route_page(); } @@ -3439,7 +3458,7 @@ void refresh_route_list() if (s_route_names.empty()) { - if (state.mode == TrackerPageState::Mode::Route) + if (state.mode != TrackerPageState::Mode::Record) { update_route_page(); } @@ -3448,7 +3467,7 @@ void refresh_route_list() std::sort(s_route_names.begin(), s_route_names.end()); (void)repair_degraded_active_route_from_list(); - if (state.mode == TrackerPageState::Mode::Route) + if (state.mode != TrackerPageState::Mode::Record) { update_route_page(); } @@ -3479,6 +3498,17 @@ void sync_active_route_from_config() void on_start_stop_clicked(lv_event_t*) { + if (g_tracker_state.mode == TrackerPageState::Mode::Navigation) + { + navigation_dialog::open(g_tracker_state.root, [](const char* path, void*) + { + auto& state = g_tracker_state; + state.selected_route = path_basename(path); + refresh_route_list(); + update_route_page(); + open_route_preview_page(); }); + return; + } const bool was_recording = platform::ui::tracker::is_recording(); if (was_recording) { @@ -3514,6 +3544,14 @@ void on_mode_route_clicked(lv_event_t*) focus_main_panel(); } +void on_mode_navigation(lv_event_t* event) +{ + const auto code = lv_event_get_code(event); + if (code == LV_EVENT_KEY && lv_event_get_key(event) != LV_KEY_ENTER) return; + set_mode(TrackerPageState::Mode::Navigation); + if (code != LV_EVENT_FOCUSED) focus_main_panel(); +} + void on_list_item_clicked(lv_event_t* e) { if (!e) @@ -3736,7 +3774,7 @@ void open_action_menu_modal() } }; - if (state.mode == TrackerPageState::Mode::Route) + if (state.mode != TrackerPageState::Mode::Record) { if (can_load_selected_route()) { @@ -3909,7 +3947,7 @@ void on_route_load_clicked(lv_event_t*) } edit.config().route_enabled = true; char path[96]; - snprintf(path, sizeof(path), "%s/%s", platform::ui::route_storage::route_dir(), state.active_route.c_str()); + snprintf(path, sizeof(path), "%s", route_path_for_name(state.active_route).c_str()); strncpy(edit.config().route_path, path, sizeof(edit.config().route_path) - 1); edit.config().route_path[sizeof(edit.config().route_path) - 1] = '\0'; edit.commit(app::AppConfigChangeSet::route()); @@ -3999,7 +4037,7 @@ void on_del_confirm_clicked(lv_event_t*) state.selected_route_idx = -1; state.selected_route.clear(); refresh_route_list(); - if (state.mode == TrackerPageState::Mode::Route) + if (state.mode != TrackerPageState::Mode::Record) { update_route_page(); } @@ -4057,7 +4095,7 @@ void open_delete_confirm_modal() } state.pending_delete_mode = TrackerPageState::Mode::Route; state.pending_delete_name = s_route_names[state.selected_route_idx].c_str(); - std::string path = std::string(platform::ui::route_storage::route_dir()) + "/" + s_route_names[state.selected_route_idx]; + std::string path = route_path_for_name(s_route_names[state.selected_route_idx]); state.pending_delete_path = path; } @@ -4153,6 +4191,19 @@ void init_page(lv_obj_t* parent) ::ui::i18n::set_label_text(state.mode_route_label, "Routes"); lv_obj_center(state.mode_route_label); + if (platform::ui::navigation::supported()) + { + state.mode_navigation_btn = lv_btn_create(state.filter_panel); + lv_obj_set_size(state.mode_navigation_btn, LV_PCT(100), filter_button_height()); + state.mode_navigation_label = lv_label_create(state.mode_navigation_btn); + lv_obj_add_style(state.mode_navigation_label, &s_btn_label, LV_PART_MAIN); + ::ui::i18n::set_label_text(state.mode_navigation_label, "Navigation"); + lv_obj_center(state.mode_navigation_label); + lv_obj_add_event_cb(state.mode_navigation_btn, on_mode_navigation, LV_EVENT_CLICKED, nullptr); + lv_obj_add_event_cb(state.mode_navigation_btn, on_mode_navigation, LV_EVENT_FOCUSED, nullptr); + lv_obj_add_event_cb(state.mode_navigation_btn, on_mode_navigation, LV_EVENT_KEY, nullptr); + } + state.status_label = lv_label_create(state.list_panel); ::ui::i18n::set_label_text(state.status_label, "Not recording"); lv_obj_set_width(state.status_label, LV_PCT(100)); @@ -4194,6 +4245,7 @@ void init_page(lv_obj_t* parent) void cleanup_page() { + navigation_dialog::close(); auto& state = g_tracker_state; if (state.card_timer) { diff --git a/modules/ui_shared/src/ui/screens/tracker/tracker_page_input.cpp b/modules/ui_shared/src/ui/screens/tracker/tracker_page_input.cpp index 9f30b677..c4ff7823 100644 --- a/modules/ui_shared/src/ui/screens/tracker/tracker_page_input.cpp +++ b/modules/ui_shared/src/ui/screens/tracker/tracker_page_input.cpp @@ -33,7 +33,7 @@ static lv_obj_t* get_top_back_button(void* /*ctx*/) static size_t get_filter_count(void* /*ctx*/) { - return 2; + return tracker::ui::g_tracker_state.mode_navigation_btn ? 3 : 2; } static lv_obj_t* get_filter_button(void* /*ctx*/, size_t index) @@ -45,6 +45,8 @@ static lv_obj_t* get_filter_button(void* /*ctx*/, size_t index) return state.mode_record_btn; case 1: return state.mode_route_btn; + case 2: + return state.mode_navigation_btn; default: return nullptr; } @@ -52,7 +54,7 @@ static lv_obj_t* get_filter_button(void* /*ctx*/, size_t index) static int get_preferred_filter_index(void* /*ctx*/) { - return tracker::ui::g_tracker_state.mode == tracker::ui::TrackerPageState::Mode::Record ? 0 : 1; + return static_cast(tracker::ui::g_tracker_state.mode); } static bool is_focusable_list_child(lv_obj_t* obj) @@ -97,7 +99,7 @@ static lv_obj_t* get_list_back_button(void* /*ctx*/) static bool include_start_stop_button() { auto& state = tracker::ui::g_tracker_state; - if (state.mode != tracker::ui::TrackerPageState::Mode::Record) + if (state.mode == tracker::ui::TrackerPageState::Mode::Route) { return false; } diff --git a/packs/ar/locales/ar/strings.tsv b/packs/ar/locales/ar/strings.tsv index 7e60870c..d1d6e19f 100644 --- a/packs/ar/locales/ar/strings.tsv +++ b/packs/ar/locales/ar/strings.tsv @@ -848,3 +848,30 @@ My position unknown موقعي غير معروف No nodes لا توجد عقد Old position موقع قديم Contacts unavailable جهات الاتصال غير متاحة +Map Help مساعدة الخريطة +Map layer: %s طبقة الخريطة: %s +Show contours إظهار خطوط الكنتور +Hide contours إخفاء خطوط الكنتور +Search POI البحث عن أماكن +Load navigation تحميل مسار الملاحة +Load track تحميل المسار المسجل +No saved navigation لا توجد ملاحة محفوظة +Help مساعدة +Menu القائمة +Navigation الملاحة +New navigation ملاحة جديدة +Current location الموقع الحالي +Use current location استخدام الموقع الحالي +Choose destination اختيار الوجهة +Calculate route حساب المسار +Save and preview حفظ ومعاينة +Calculating route... جارٍ حساب المسار... +Route ready: %.1f km المسار جاهز: %.1f كم +No TMAP routing data لا توجد بيانات توجيه TMAP +No path found لم يتم العثور على مسار +Outside routing coverage خارج تغطية التوجيه +Navigation memory limit تم بلوغ حد ذاكرة الملاحة +Navigation unavailable الملاحة غير متاحة +Origin نقطة البداية +Destination الوجهة +Tap the origin to search for a place اضغط على نقطة البداية للبحث عن مكان diff --git a/packs/europe-cyrillic-ext/locales/ru/strings.tsv b/packs/europe-cyrillic-ext/locales/ru/strings.tsv index 46c02e34..8101d70c 100644 --- a/packs/europe-cyrillic-ext/locales/ru/strings.tsv +++ b/packs/europe-cyrillic-ext/locales/ru/strings.tsv @@ -848,3 +848,30 @@ My position unknown Моё положение неизвестно No nodes Нет узлов Old position Устаревшее положение Contacts unavailable Контакты недоступны +Map Help Справка карты +Map layer: %s Слой карты: %s +Show contours Показать горизонтали +Hide contours Скрыть горизонтали +Search POI Поиск мест +Load navigation Загрузить навигацию +Load track Загрузить трек +No saved navigation Нет сохранённой навигации +Help Справка +Menu Меню +Navigation Навигация +New navigation Новый маршрут +Current location Текущее положение +Use current location Использовать текущее положение +Choose destination Выбрать пункт назначения +Calculate route Рассчитать маршрут +Save and preview Сохранить и просмотреть +Calculating route... Расчёт маршрута... +Route ready: %.1f km Маршрут готов: %.1f км +No TMAP routing data Нет данных маршрутизации TMAP +No path found Путь не найден +Outside routing coverage Вне покрытия дорожной сети +Navigation memory limit Достигнут лимит памяти навигации +Navigation unavailable Навигация недоступна +Origin Начальная точка +Destination Пункт назначения +Tap the origin to search for a place Нажмите на начальную точку для поиска места diff --git a/packs/europe-latin-ext/locales/de/strings.tsv b/packs/europe-latin-ext/locales/de/strings.tsv index f57d9f94..e8917fd7 100644 --- a/packs/europe-latin-ext/locales/de/strings.tsv +++ b/packs/europe-latin-ext/locales/de/strings.tsv @@ -848,3 +848,30 @@ My position unknown Eigene Position unbekannt No nodes Keine Knoten Old position Veraltete Position Contacts unavailable Kontakte nicht verfügbar +Map Help Kartenhilfe +Map layer: %s Kartenebene: %s +Show contours Höhenlinien anzeigen +Hide contours Höhenlinien ausblenden +Search POI Orte suchen +Load navigation Navigation laden +Load track Track laden +No saved navigation Keine gespeicherte Navigation +Help Hilfe +Menu Menü +Navigation Navigation +New navigation Neue Navigation +Current location Aktueller Standort +Use current location Aktuellen Standort verwenden +Choose destination Ziel auswählen +Calculate route Route berechnen +Save and preview Speichern und ansehen +Calculating route... Route wird berechnet... +Route ready: %.1f km Route bereit: %.1f km +No TMAP routing data Keine TMAP-Routingdaten +No path found Kein Weg gefunden +Outside routing coverage Außerhalb der Routingabdeckung +Navigation memory limit Speichergrenze der Navigation erreicht +Navigation unavailable Navigation nicht verfügbar +Origin Startpunkt +Destination Ziel +Tap the origin to search for a place Startpunkt antippen, um einen Ort zu suchen diff --git a/packs/europe-latin-ext/locales/es/strings.tsv b/packs/europe-latin-ext/locales/es/strings.tsv index 2cd697b7..cce15e0f 100644 --- a/packs/europe-latin-ext/locales/es/strings.tsv +++ b/packs/europe-latin-ext/locales/es/strings.tsv @@ -848,3 +848,30 @@ My position unknown Mi posición es desconocida No nodes Sin nodos Old position Posición antigua Contacts unavailable Contactos no disponibles +Map Help Ayuda del mapa +Map layer: %s Capa del mapa: %s +Show contours Mostrar curvas de nivel +Hide contours Ocultar curvas de nivel +Search POI Buscar lugares +Load navigation Cargar navegación +Load track Cargar recorrido +No saved navigation No hay navegaciones guardadas +Help Ayuda +Menu Menú +Navigation Navegación +New navigation Nueva navegación +Current location Ubicación actual +Use current location Usar ubicación actual +Choose destination Elegir destino +Calculate route Calcular ruta +Save and preview Guardar y ver +Calculating route... Calculando ruta... +Route ready: %.1f km Ruta lista: %.1f km +No TMAP routing data Sin datos de rutas TMAP +No path found No se encontró un camino +Outside routing coverage Fuera de la cobertura de rutas +Navigation memory limit Límite de memoria de navegación +Navigation unavailable Navegación no disponible +Origin Origen +Destination Destino +Tap the origin to search for a place Toca el origen para buscar un lugar diff --git a/packs/europe-latin-ext/locales/fr/strings.tsv b/packs/europe-latin-ext/locales/fr/strings.tsv index 28209d94..235fe480 100644 --- a/packs/europe-latin-ext/locales/fr/strings.tsv +++ b/packs/europe-latin-ext/locales/fr/strings.tsv @@ -848,3 +848,30 @@ My position unknown Ma position est inconnue No nodes Aucun nœud Old position Position ancienne Contacts unavailable Contacts indisponibles +Map Help Aide de la carte +Map layer: %s Fond de carte : %s +Show contours Afficher les courbes de niveau +Hide contours Masquer les courbes de niveau +Search POI Rechercher des lieux +Load navigation Charger une navigation +Load track Charger une trace +No saved navigation Aucune navigation enregistrée +Help Aide +Menu Menu +Navigation Navigation +New navigation Nouvelle navigation +Current location Position actuelle +Use current location Utiliser la position actuelle +Choose destination Choisir la destination +Calculate route Calculer le parcours +Save and preview Enregistrer et prévisualiser +Calculating route... Calcul du parcours... +Route ready: %.1f km Parcours prêt : %.1f km +No TMAP routing data Aucune donnée de routage TMAP +No path found Aucun chemin trouvé +Outside routing coverage Hors couverture du réseau +Navigation memory limit Limite de mémoire de navigation atteinte +Navigation unavailable Navigation indisponible +Origin Départ +Destination Destination +Tap the origin to search for a place Touchez le départ pour rechercher un lieu diff --git a/packs/europe-latin-ext/locales/it/strings.tsv b/packs/europe-latin-ext/locales/it/strings.tsv index ed32f74c..376bf031 100644 --- a/packs/europe-latin-ext/locales/it/strings.tsv +++ b/packs/europe-latin-ext/locales/it/strings.tsv @@ -848,3 +848,30 @@ My position unknown La mia posizione è sconosciuta No nodes Nessun nodo Old position Posizione precedente Contacts unavailable Contatti non disponibili +Map Help Guida della mappa +Map layer: %s Livello mappa: %s +Show contours Mostra curve di livello +Hide contours Nascondi curve di livello +Search POI Cerca luoghi +Load navigation Carica navigazione +Load track Carica traccia +No saved navigation Nessuna navigazione salvata +Help Aiuto +Menu Menu +Navigation Navigazione +New navigation Nuova navigazione +Current location Posizione attuale +Use current location Usa posizione attuale +Choose destination Scegli destinazione +Calculate route Calcola percorso +Save and preview Salva e visualizza +Calculating route... Calcolo del percorso... +Route ready: %.1f km Percorso pronto: %.1f km +No TMAP routing data Nessun dato di navigazione TMAP +No path found Nessun percorso trovato +Outside routing coverage Fuori dalla copertura stradale +Navigation memory limit Limite di memoria della navigazione +Navigation unavailable Navigazione non disponibile +Origin Partenza +Destination Destinazione +Tap the origin to search for a place Tocca la partenza per cercare un luogo diff --git a/packs/europe-latin-ext/locales/nl/strings.tsv b/packs/europe-latin-ext/locales/nl/strings.tsv index 8c8cad82..1b7564e3 100644 --- a/packs/europe-latin-ext/locales/nl/strings.tsv +++ b/packs/europe-latin-ext/locales/nl/strings.tsv @@ -848,3 +848,30 @@ My position unknown Eigen positie onbekend No nodes Geen knooppunten Old position Verouderde positie Contacts unavailable Contacten niet beschikbaar +Map Help Kaarthulp +Map layer: %s Kaartlaag: %s +Show contours Hoogtelijnen tonen +Hide contours Hoogtelijnen verbergen +Search POI Plaatsen zoeken +Load navigation Navigatie laden +Load track Track laden +No saved navigation Geen opgeslagen navigatie +Help Hulp +Menu Menu +Navigation Navigatie +New navigation Nieuwe navigatie +Current location Huidige locatie +Use current location Huidige locatie gebruiken +Choose destination Bestemming kiezen +Calculate route Route berekenen +Save and preview Opslaan en bekijken +Calculating route... Route berekenen... +Route ready: %.1f km Route gereed: %.1f km +No TMAP routing data Geen TMAP-routegegevens +No path found Geen pad gevonden +Outside routing coverage Buiten het routenetwerk +Navigation memory limit Geheugenlimiet voor navigatie bereikt +Navigation unavailable Navigatie niet beschikbaar +Origin Vertrekpunt +Destination Bestemming +Tap the origin to search for a place Tik op het vertrekpunt om een plaats te zoeken diff --git a/packs/europe-latin-ext/locales/pl/strings.tsv b/packs/europe-latin-ext/locales/pl/strings.tsv index 11071e04..67626497 100644 --- a/packs/europe-latin-ext/locales/pl/strings.tsv +++ b/packs/europe-latin-ext/locales/pl/strings.tsv @@ -848,3 +848,30 @@ My position unknown Moja pozycja jest nieznana No nodes Brak węzłów Old position Nieaktualna pozycja Contacts unavailable Kontakty niedostępne +Map Help Pomoc mapy +Map layer: %s Warstwa mapy: %s +Show contours Pokaż poziomice +Hide contours Ukryj poziomice +Search POI Szukaj miejsc +Load navigation Wczytaj nawigację +Load track Wczytaj ślad +No saved navigation Brak zapisanej nawigacji +Help Pomoc +Menu Menu +Navigation Nawigacja +New navigation Nowa nawigacja +Current location Bieżąca lokalizacja +Use current location Użyj bieżącej lokalizacji +Choose destination Wybierz cel +Calculate route Oblicz trasę +Save and preview Zapisz i wyświetl +Calculating route... Obliczanie trasy... +Route ready: %.1f km Trasa gotowa: %.1f km +No TMAP routing data Brak danych nawigacyjnych TMAP +No path found Nie znaleziono drogi +Outside routing coverage Poza zasięgiem sieci dróg +Navigation memory limit Osiągnięto limit pamięci nawigacji +Navigation unavailable Nawigacja niedostępna +Origin Początek +Destination Cel +Tap the origin to search for a place Dotknij początku, aby wyszukać miejsce diff --git a/packs/europe-latin-ext/locales/pt-PT/strings.tsv b/packs/europe-latin-ext/locales/pt-PT/strings.tsv index 398b1414..ce6aac49 100644 --- a/packs/europe-latin-ext/locales/pt-PT/strings.tsv +++ b/packs/europe-latin-ext/locales/pt-PT/strings.tsv @@ -848,3 +848,30 @@ My position unknown A minha posição é desconhecida No nodes Sem nós Old position Posição antiga Contacts unavailable Contactos indisponíveis +Map Help Ajuda do mapa +Map layer: %s Camada do mapa: %s +Show contours Mostrar curvas de nível +Hide contours Ocultar curvas de nível +Search POI Pesquisar locais +Load navigation Carregar navegação +Load track Carregar percurso +No saved navigation Sem navegações guardadas +Help Ajuda +Menu Menu +Navigation Navegação +New navigation Nova navegação +Current location Localização atual +Use current location Usar localização atual +Choose destination Escolher destino +Calculate route Calcular percurso +Save and preview Guardar e pré-visualizar +Calculating route... A calcular percurso... +Route ready: %.1f km Percurso pronto: %.1f km +No TMAP routing data Sem dados de navegação TMAP +No path found Não foi encontrado um caminho +Outside routing coverage Fora da cobertura de percursos +Navigation memory limit Limite de memória da navegação +Navigation unavailable Navegação indisponível +Origin Origem +Destination Destino +Tap the origin to search for a place Toque na origem para procurar um local diff --git a/packs/ja/locales/ja/strings.tsv b/packs/ja/locales/ja/strings.tsv index 406f5477..a7292379 100644 --- a/packs/ja/locales/ja/strings.tsv +++ b/packs/ja/locales/ja/strings.tsv @@ -848,3 +848,30 @@ My position unknown 現在地不明 No nodes ノードなし Old position 古い位置情報 Contacts unavailable 連絡先を利用できません +Map Help 地図ヘルプ +Map layer: %s 地図レイヤー: %s +Show contours 等高線を表示 +Hide contours 等高線を非表示 +Search POI 周辺施設を検索 +Load navigation ナビを読み込む +Load track 軌跡を読み込む +No saved navigation 保存済みのナビはありません +Help ヘルプ +Menu メニュー +Navigation ナビゲーション +New navigation 新しいナビゲーション +Current location 現在地 +Use current location 現在地を使う +Choose destination 目的地を選択 +Calculate route 経路を計算 +Save and preview 保存してプレビュー +Calculating route... 経路を計算中... +Route ready: %.1f km 経路の準備完了:%.1f km +No TMAP routing data TMAPの経路データがありません +No path found 経路が見つかりません +Outside routing coverage 経路データの範囲外です +Navigation memory limit ナビゲーションのメモリ上限に達しました +Navigation unavailable ナビゲーションを利用できません +Origin 出発地 +Destination 目的地 +Tap the origin to search for a place 出発地をタップして場所を検索 diff --git a/packs/ko/locales/ko/strings.tsv b/packs/ko/locales/ko/strings.tsv index 2d845b59..68cd676d 100644 --- a/packs/ko/locales/ko/strings.tsv +++ b/packs/ko/locales/ko/strings.tsv @@ -848,3 +848,30 @@ My position unknown 현재 위치 알 수 없음 No nodes 노드 없음 Old position 오래된 위치 Contacts unavailable 연락처를 사용할 수 없음 +Map Help 지도 도움말 +Map layer: %s 지도 레이어: %s +Show contours 등고선 표시 +Hide contours 등고선 숨기기 +Search POI 관심 지점 검색 +Load navigation 내비게이션 불러오기 +Load track 이동 기록 불러오기 +No saved navigation 저장된 내비게이션 없음 +Help 도움말 +Menu 메뉴 +Navigation 내비게이션 +New navigation 새 내비게이션 +Current location 현재 위치 +Use current location 현재 위치 사용 +Choose destination 목적지 선택 +Calculate route 경로 계산 +Save and preview 저장하고 미리 보기 +Calculating route... 경로 계산 중... +Route ready: %.1f km 경로 준비 완료: %.1f km +No TMAP routing data TMAP 경로 데이터 없음 +No path found 경로를 찾을 수 없음 +Outside routing coverage 경로 데이터 범위 밖 +Navigation memory limit 내비게이션 메모리 한도 도달 +Navigation unavailable 내비게이션 사용 불가 +Origin 출발지 +Destination 목적지 +Tap the origin to search for a place 출발지를 눌러 장소 검색 diff --git a/packs/zh-Hans/locales/zh-Hans/strings.tsv b/packs/zh-Hans/locales/zh-Hans/strings.tsv index 582904cf..01f1ada6 100644 --- a/packs/zh-Hans/locales/zh-Hans/strings.tsv +++ b/packs/zh-Hans/locales/zh-Hans/strings.tsv @@ -848,3 +848,30 @@ My position unknown 本机位置未知 No nodes 暂无节点 Old position 旧位置 Contacts unavailable 联系人暂不可用 +Map Help 地图帮助 +Map layer: %s 地图图层:%s +Show contours 显示等高线 +Hide contours 隐藏等高线 +Search POI 搜索兴趣点 +Load navigation 加载导航 +Load track 加载轨迹 +No saved navigation 暂无已保存的导航 +Help 帮助 +Menu 菜单 +Navigation 导航 +New navigation 新建导航 +Current location 当前位置 +Use current location 使用当前位置 +Choose destination 选择目的地 +Calculate route 计算路径 +Save and preview 保存并预览 +Calculating route... 正在计算路径… +Route ready: %.1f km 路径已就绪:%.1f 公里 +No TMAP routing data 没有可用的 TMAP 路网数据 +No path found 未找到可通行路径 +Outside routing coverage 超出路网覆盖范围 +Navigation memory limit 导航计算超出内存限额 +Navigation unavailable 导航暂不可用 +Origin 起始地 +Destination 目的地 +Tap the origin to search for a place 点击起始地可搜索地名 diff --git a/packs/zh-Hant/locales/zh-Hant/strings.tsv b/packs/zh-Hant/locales/zh-Hant/strings.tsv index 7a57c086..72ea3331 100644 --- a/packs/zh-Hant/locales/zh-Hant/strings.tsv +++ b/packs/zh-Hant/locales/zh-Hant/strings.tsv @@ -848,3 +848,30 @@ My position unknown 本機位置未知 No nodes 暫無節點 Old position 舊位置 Contacts unavailable 聯絡人暫不可用 +Map Help 地圖說明 +Map layer: %s 地圖圖層:%s +Show contours 顯示等高線 +Hide contours 隱藏等高線 +Search POI 搜尋興趣點 +Load navigation 載入導航 +Load track 載入軌跡 +No saved navigation 尚無已儲存的導航 +Help 說明 +Menu 選單 +Navigation 導航 +New navigation 新增導航 +Current location 目前位置 +Use current location 使用目前位置 +Choose destination 選擇目的地 +Calculate route 計算路徑 +Save and preview 儲存並預覽 +Calculating route... 正在計算路徑… +Route ready: %.1f km 路徑已就緒:%.1f 公里 +No TMAP routing data 沒有可用的 TMAP 路網資料 +No path found 未找到可通行路徑 +Outside routing coverage 超出路網涵蓋範圍 +Navigation memory limit 導航計算超出記憶體限額 +Navigation unavailable 導航暫時無法使用 +Origin 起始地 +Destination 目的地 +Tap the origin to search for a place 點選起始地可搜尋地名 diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/map_tiles/sd_tmap_storage.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/map_tiles/sd_tmap_storage.h index 5a2ae28d..d5ddebe6 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/map_tiles/sd_tmap_storage.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/map_tiles/sd_tmap_storage.h @@ -11,7 +11,7 @@ namespace platform::esp::map_tiles { -class SdTmapStorage final : public ui::map_tiles::TmapStorage +class SdTmapStorage final : public ::ui::map_tiles::TmapStorage { using File = arduino_common::storage::SdRuntimeFile; using Dir = arduino_common::storage::SdRuntimeDir; @@ -19,6 +19,7 @@ class SdTmapStorage final : public ui::map_tiles::TmapStorage using DirStatus = arduino_common::storage::SdDirReadStatus; public: + explicit SdTmapStorage(bool routing = false) : routing_(routing) {} // Optional caller-owned search cache. Allocate in PSRAM, never on stack. struct ReadCache { @@ -48,7 +49,10 @@ class SdTmapStorage final : public ui::map_tiles::TmapStorage tmap::Status nextPackage(char* path, size_t capacity) override { if (ended_) return tmap::Status::Missing; - static constexpr const char* roots[] = {"/maps/tmap/osm", "/maps/tmap/terrain", "/maps/tmap/satellite", "/maps/tmap"}; + static constexpr const char* raster_roots[] = {"/maps/tmap/osm", "/maps/tmap/terrain", "/maps/tmap/satellite", "/maps/tmap"}; + static constexpr const char* routing_roots[] = {"/maps/navigation", "/maps/tmap/navigation", "/maps/tmap/osm", "/maps/tmap/terrain", "/maps/tmap/satellite", "/maps/tmap"}; + const auto* roots = routing_ ? routing_roots : raster_roots; + const size_t root_count = routing_ ? sizeof(routing_roots) / sizeof(routing_roots[0]) : sizeof(raster_roots) / sizeof(raster_roots[0]); const auto* root = roots[root_index_]; if (!directory_.is_open()) { @@ -56,7 +60,7 @@ class SdTmapStorage final : public ui::map_tiles::TmapStorage if (status == OpenStatus::Missing) { path[0] = 0; - ended_ = ++root_index_ == sizeof(roots) / sizeof(roots[0]); + ended_ = ++root_index_ == root_count; return ended_ ? tmap::Status::Missing : tmap::Status::More; } if (status == OpenStatus::Busy || status == OpenStatus::Unavailable) return tmap::Status::Busy; @@ -74,7 +78,7 @@ class SdTmapStorage final : public ui::map_tiles::TmapStorage { directory_.close(); path[0] = 0; - ended_ = ++root_index_ == sizeof(roots) / sizeof(roots[0]); + ended_ = ++root_index_ == root_count; return ended_ ? tmap::Status::Missing : tmap::Status::More; } if (status != DirStatus::Entry) return tmap::Status::IoError; @@ -346,6 +350,7 @@ class SdTmapStorage final : public ui::map_tiles::TmapStorage uint64_t raw_offset_ = 0, position_ = 0; size_t raw_bytes_ = 0, raw_completed_ = 0; bool position_valid_ = false; + bool routing_ = false; File file_; Dir directory_; uint64_t size_ = 0; diff --git a/platform/esp/arduino_common/src/platform_ui_navigation_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_navigation_runtime.cpp new file mode 100644 index 00000000..8b86885c --- /dev/null +++ b/platform/esp/arduino_common/src/platform_ui_navigation_runtime.cpp @@ -0,0 +1,363 @@ +#include "platform/ui/navigation_runtime.h" +#if defined(ARDUINO_ARCH_ESP32) +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "platform/esp/arduino_common/map_tiles/sd_tmap_storage.h" +#include "ui_map_runtime/navigation/route_calculation.h" +#include "ui_map_runtime/navigation/route_kml_writer.h" +#include +#include +#include +#include +#include +#include +#include + +namespace platform::ui::navigation +{ +namespace +{ +namespace sd = esp::arduino_common::storage; +constexpr const char* kDirectory = "/routes/navigation"; +class Memory final : public tmap::MemoryProvider +{ + public: + bool hasExternalMemory() const override { return heap_caps_get_total_size(MALLOC_CAP_SPIRAM) != 0; } + void* allocate(size_t bytes, size_t alignment, tmap::MemoryRegion region) override + { + if (region != tmap::MemoryRegion::External) return nullptr; + return heap_caps_aligned_alloc(alignment, bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + } + void release(void* pointer, size_t, size_t, tmap::MemoryRegion) override { heap_caps_free(pointer); } +}; +struct State +{ + Request request{}; + Snapshot snapshot{}; + Memory memory; + esp::map_tiles::SdTmapStorage maps{true}; + ::navigation::RouteCalculation calculation{memory, tmap::MemoryPolicy::RequireExternal}; + ::navigation::RouteKmlWriter writer; + sd::SdRuntimeFile output{sd::SdRuntimeMemoryPolicy::PsramOnly}; + std::atomic cancelled{false}; + bool running = true; + uint32_t media = 0; + char package[192]{}, pending[160]{}, saved[160]{}; +}; +State* s_state = nullptr; +SemaphoreHandle_t s_mutex = nullptr; +void endpointFilename(char* output, size_t capacity, const char* name) +{ + size_t length = std::min(std::strlen(name), capacity - 1); + // Truncate at a complete UTF-8 boundary, including non-Latin POI names. + while (length && (static_cast(name[length]) & 0xc0) == 0x80) --length; + for (size_t index = 0; index < length; ++index) + { + const auto byte = static_cast(name[index]); + output[index] = byte < 32 || std::strchr("/\\:*?\"<>|", byte) ? '_' : name[index]; + } + while (length && (output[length - 1] == '.' || output[length - 1] == ' ')) --length; + output[length] = 0; + if (!length) std::snprintf(output, capacity, "place"); +} +void destroy(State* state) +{ + if (!state) return; + state->~State(); + heap_caps_free(state); +} +bool valid(const Endpoint& endpoint) +{ + return endpoint.latitude_e7 >= -900000000 && endpoint.latitude_e7 <= 900000000 && + endpoint.longitude_e7 >= -1800000000 && endpoint.longitude_e7 <= 1800000000 && + endpoint.name[0] && std::memchr(endpoint.name, 0, sizeof(endpoint.name)); +} +bool alive(const State& state) +{ + return !state.cancelled && state.media == sd::sd_media_session() && sd::sd_card_ready(); +} +void publish(State& state, Status status) +{ + xSemaphoreTake(s_mutex, portMAX_DELAY); + state.snapshot.status = status; + ++state.snapshot.revision; + xSemaphoreGive(s_mutex); +} +bool write(void* context, const char* bytes, size_t count) +{ + auto& state = *static_cast(context); + size_t offset = 0; + const auto started = state.maps.nowMs(); + while (offset < count && alive(state)) + { + const auto written = state.output.write(bytes + offset, count - offset); + if (written > count - offset) return false; + offset += written; + if (offset == count) return true; + if (!state.output.read_busy() || state.maps.nowMs() - started >= 5000) return false; + vTaskDelay(pdMS_TO_TICKS(10)); + } + return false; +} +bool point(void* context, tmap::GeoPoint value) +{ + return static_cast(context)->writer.point(value); +} +Status failure(tmap::Result result) +{ + switch (result) + { + case tmap::Result::NoPath: + return Status::NoPath; + case tmap::Result::OutsideCoverage: + case tmap::Result::Incomplete: + return Status::OutsideCoverage; + case tmap::Result::BudgetExceeded: + return Status::MemoryUnavailable; + case tmap::Result::Missing: + case tmap::Result::Unsupported: + case tmap::Result::Invalid: + return Status::NoRoutingData; + case tmap::Result::Cancelled: + return Status::Cancelled; + default: + return Status::StorageUnavailable; + } +} +void worker(void* context) +{ + auto& state = *static_cast(context); + auto status = Status::NoRoutingData; + bool active = false; + uint32_t busy_since = state.maps.nowMs(); + while (alive(state)) + { + if (!state.package[0]) + { + const auto next = state.maps.nextPackage(state.package, sizeof(state.package)); + if (next == tmap::Status::Missing) break; + if (next == tmap::Status::More || next == tmap::Status::Busy) + { + vTaskDelay(1); + continue; + } + if (next != tmap::Status::Ok) + { + status = Status::StorageUnavailable; + break; + } + } + if (!active) + { + const auto opened = state.maps.openPackage(state.package); + if (opened == tmap::Status::Busy || opened == tmap::Status::More) + { + if (state.maps.nowMs() - busy_since >= 30000) + { + status = Status::StorageUnavailable; + break; + } + vTaskDelay(1); + continue; + } + if (opened != tmap::Status::Ok) + { + status = Status::StorageUnavailable; + break; + } + const auto result = state.calculation.begin(state.maps, + {state.request.origin.latitude_e7, state.request.origin.longitude_e7}, + {state.request.destination.latitude_e7, state.request.destination.longitude_e7}, tmap::TravelMode::Foot, {}); + if (!::navigation::RouteCalculation::pending(result)) + { + status = failure(result); + break; + } + active = true; + } + const auto result = state.calculation.step(16); + if (::navigation::RouteCalculation::pending(result)) + { + if (result == tmap::Result::Busy && state.maps.nowMs() - busy_since >= 30000) + { + status = Status::StorageUnavailable; + break; + } + if (result != tmap::Result::Busy) busy_since = state.maps.nowMs(); + vTaskDelay(1); + continue; + } + if (result != tmap::Result::Ok) + { + const auto reason = failure(result); + if (reason == Status::StorageUnavailable || reason == Status::MemoryUnavailable) + { + status = reason; + break; + } + if (reason != Status::NoRoutingData) status = reason; + state.calculation.reset(); + state.maps.closePackage(); + state.package[0] = 0; + active = false; + busy_since = state.maps.nowMs(); + continue; + } + const double distance = state.calculation.costUnits() / 100.0; + if (!sd::sd_mkdir("/routes", state.media) || !sd::sd_mkdir(kDirectory, state.media) || + !state.output.open(state.pending, "w", state.media) || + !state.writer.begin(write, &state, state.request.origin.name, + {state.request.origin.latitude_e7, state.request.origin.longitude_e7}, state.request.destination.name, + {state.request.destination.latitude_e7, state.request.destination.longitude_e7})) + { + status = Status::StorageUnavailable; + break; + } + auto geometry = state.calculation.beginGeometry(point, &state); + while (alive(state) && ::navigation::RouteCalculation::pending(geometry)) + { + geometry = state.calculation.step(4); + vTaskDelay(1); + } + if (!alive(state)) + { + status = Status::Cancelled; + break; + } + if (geometry != tmap::Result::Ok || !state.writer.finish() || !state.output.flush()) + { + status = Status::StorageUnavailable; + break; + } + state.output.close(); + xSemaphoreTake(s_mutex, portMAX_DELAY); + state.snapshot.distance_m = distance; + state.snapshot.points = static_cast(state.writer.pointCount()); + std::strcpy(state.snapshot.path, state.pending); + xSemaphoreGive(s_mutex); + status = Status::Ready; + break; + } + state.output.close(); + state.calculation.reset(); + state.maps.closePackage(); + state.maps.resetEnumeration(); + if (!alive(state)) status = state.cancelled ? Status::Cancelled : Status::StorageUnavailable; + if (status != Status::Ready && sd::sd_exists(state.pending)) sd::sd_remove(state.pending, state.media); + publish(state, status); + xSemaphoreTake(s_mutex, portMAX_DELAY); + state.running = false; + if (state.cancelled) + { + s_state = nullptr; + xSemaphoreGive(s_mutex); + if (sd::sd_exists(state.pending)) sd::sd_remove(state.pending, state.media); + destroy(&state); + } + else xSemaphoreGive(s_mutex); + vTaskDelete(nullptr); +} +} // namespace +bool supported() { return heap_caps_get_total_size(MALLOC_CAP_SPIRAM) != 0; } +const char* directory() { return kDirectory; } +bool submit(const Request& request) +{ + if (!supported() || !valid(request.origin) || !valid(request.destination) || !sd::sd_card_ready()) return false; + if (!s_mutex) s_mutex = xSemaphoreCreateMutex(); + if (!s_mutex || xSemaphoreTake(s_mutex, 0) != pdTRUE) return false; + if (s_state) + { + xSemaphoreGive(s_mutex); + return false; + } + void* memory = heap_caps_aligned_alloc(alignof(State), sizeof(State), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) + { + xSemaphoreGive(s_mutex); + return false; + } + auto* state = s_state = new (memory) State{}; + state->request = request; + state->media = sd::sd_media_session(); + const auto id = static_cast(esp_random()); + std::snprintf(state->pending, sizeof(state->pending), "%s/.pending-%08lx.kml", kDirectory, id); + char origin[25], destination[25]; + endpointFilename(origin, sizeof(origin), request.origin.name); + endpointFilename(destination, sizeof(destination), request.destination.name); + std::snprintf(state->saved, sizeof(state->saved), "%s/nav-%s-%s-%08lx.kml", kDirectory, origin, destination, id); + state->snapshot.status = Status::Calculating; + state->snapshot.revision = 1; + const bool started = xTaskCreate(worker, "navigation", 4096, state, 1, nullptr) == pdPASS; + if (!started) + { + destroy(state); + s_state = nullptr; + } + xSemaphoreGive(s_mutex); + return started; +} +bool poll(Snapshot& snapshot) +{ + if (!s_mutex || xSemaphoreTake(s_mutex, 0) != pdTRUE) return false; + const bool changed = s_state && snapshot.revision != s_state->snapshot.revision; + if (changed) snapshot = s_state->snapshot; + xSemaphoreGive(s_mutex); + return changed; +} +bool save() +{ + if (!s_mutex || xSemaphoreTake(s_mutex, 0) != pdTRUE) return false; + const bool saved = s_state && !s_state->running && s_state->snapshot.status == Status::Ready && + s_state->media == sd::sd_media_session() && !sd::sd_exists(s_state->saved) && + sd::sd_rename(s_state->pending, s_state->saved, s_state->media); + if (saved) + { + s_state->snapshot.status = Status::Saved; + std::strcpy(s_state->snapshot.path, s_state->saved); + ++s_state->snapshot.revision; + } + xSemaphoreGive(s_mutex); + return saved; +} +void cancel() +{ + if (!s_mutex || xSemaphoreTake(s_mutex, pdMS_TO_TICKS(10)) != pdTRUE) return; + auto* state = s_state; + if (state && state->running) state->cancelled = true; + else if (state) + { + if (state->snapshot.status != Status::Saved && sd::sd_exists(state->pending)) sd::sd_remove(state->pending, state->media); + s_state = nullptr; + destroy(state); + } + xSemaphoreGive(s_mutex); +} +bool list(std::vector& names, size_t maximum) +{ + names.clear(); + sd::SdRuntimeDir files(sd::SdRuntimeMemoryPolicy::PsramOnly); + const auto opened = files.open_read_status(kDirectory, sd::sd_media_session()); + if (opened == sd::SdFileReadStatus::Missing) return true; + if (opened != sd::SdFileReadStatus::Ready) return false; + char name[128]; + while (names.size() < maximum) + { + bool is_directory = false; + const auto next = files.read_next_status(name, sizeof(name), &is_directory); + if (next == sd::SdDirReadStatus::End) return true; + if (next != sd::SdDirReadStatus::Entry) return false; + const auto length = std::strlen(name); + if (!is_directory && name[0] != '.' && length > 4 && !std::strcmp(name + length - 4, ".kml")) names.emplace_back(name); + } + return true; +} +bool remove(const char* name) +{ + if (!name || !name[0] || name[0] == '.' || std::strchr(name, '/') || std::strchr(name, '\\') || std::strlen(name) >= 128) return false; + char path[160]; + std::snprintf(path, sizeof(path), "%s/%s", kDirectory, name); + return sd::sd_remove(path, sd::sd_media_session()); +} +} // namespace platform::ui::navigation +#endif diff --git a/tests/navigation/CMakeLists.txt b/tests/navigation/CMakeLists.txt new file mode 100644 index 00000000..fba520e6 --- /dev/null +++ b/tests/navigation/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.20) +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + project(trailmate_navigation LANGUAGES CXX) +endif() +set(CMAKE_CXX_STANDARD 17) +set(REPO "${CMAKE_CURRENT_LIST_DIR}/../..") +include("${REPO}/cmake/TrailMateTmap.cmake") +trailmate_add_tmap() +enable_testing() +find_package(Python3 REQUIRED COMPONENTS Interpreter) +get_target_property(sdk_source tmap SOURCE_DIR) +add_executable(navigation_calculation test_calculation.cpp) +target_include_directories(navigation_calculation PRIVATE "${REPO}/modules/ui_map_runtime/include") +target_link_libraries(navigation_calculation PRIVATE tmap::tmap) +if(MSVC) + target_compile_options(navigation_calculation PRIVATE /W4 /UNDEBUG) +else() + target_compile_options(navigation_calculation PRIVATE -Wall -Wextra -Werror -UNDEBUG) +endif() +add_test(NAME navigation_fixture COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_LIST_DIR}/make_fixture.py" + "${sdk_source}/tools" "${CMAKE_CURRENT_BINARY_DIR}/navigation.tmap") +set_tests_properties(navigation_fixture PROPERTIES FIXTURES_SETUP navigation_map) +add_test(NAME navigation_calculation COMMAND navigation_calculation "${CMAKE_CURRENT_BINARY_DIR}/navigation.tmap") +set_tests_properties(navigation_calculation PROPERTIES FIXTURES_REQUIRED navigation_map FIXTURES_SETUP calculated_navigation) +add_test(NAME navigation_saved_kml COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_LIST_DIR}/verify_saved_kml.py" + "${CMAKE_CURRENT_BINARY_DIR}/navigation.tmap.kml") +set_tests_properties(navigation_saved_kml PROPERTIES FIXTURES_REQUIRED calculated_navigation) diff --git a/tests/navigation/make_fixture.py b/tests/navigation/make_fixture.py new file mode 100644 index 00000000..99e16748 --- /dev/null +++ b/tests/navigation/make_fixture.py @@ -0,0 +1,27 @@ +"""A directed junction where a prohibited turn requires a visible detour.""" +import sys +from pathlib import Path + +sys.path.insert(0, sys.argv[1]) +from tmap_pack import write + +write({ + "name": "Trail Mate navigation junction", + "graph": { + "nodes": [ + {"id": 1, "point": [0, 0]}, + {"id": 2, "point": [0.001, 0]}, + {"id": 3, "point": [0.002, 0]}, + {"id": 4, "point": [0.001, 0.001]}, + ], + "edges": [ + {"id": "approach", "from": 1, "to": 2}, + {"id": "direct", "from": 2, "to": 3}, + {"id": "detour-a", "from": 2, "to": 4}, + {"id": "detour-b", "from": 4, "to": 3}, + ], + "restrictions": [ + {"from": "approach", "to": "direct", "access": 1, "kind": "no"} + ], + }, +}, Path(sys.argv[2])) diff --git a/tests/navigation/test_calculation.cpp b/tests/navigation/test_calculation.cpp new file mode 100644 index 00000000..339fcef0 --- /dev/null +++ b/tests/navigation/test_calculation.cpp @@ -0,0 +1,114 @@ +#include "ui_map_runtime/navigation/route_calculation.h" +#include "ui_map_runtime/navigation/route_kml_writer.h" +#include +#include +#include +#include +#include +#include +#include + +class Memory : public tmap::MemoryProvider +{ + public: + std::map active; + size_t bytes = 0, peak = 0; + bool hasExternalMemory() const override { return true; } + void* allocate(size_t size, size_t, tmap::MemoryRegion region) override + { + assert(region == tmap::MemoryRegion::External); + auto* pointer = std::malloc(size); + assert(pointer); + active[pointer] = size; + bytes += size; + peak = std::max(peak, bytes); + return pointer; + } + void release(void* pointer, size_t size, size_t, tmap::MemoryRegion region) override + { + assert(region == tmap::MemoryRegion::External && active.at(pointer) == size); + active.erase(pointer); + bytes -= size; + std::free(pointer); + } +}; +class File : public tmap::RandomAccessFile +{ + public: + std::vector data; + size_t calls = 0, busy_every = 0; + uint64_t size() const override { return data.size(); } + tmap::Status readAt(uint64_t offset, uint8_t* output, size_t count) override + { + if (busy_every && ++calls % busy_every == 0) return tmap::Status::Busy; + if (offset > data.size() || count > data.size() - offset) return tmap::Status::Invalid; + std::copy_n(data.data() + offset, count, output); + return tmap::Status::Ok; + } +}; +struct Geometry +{ + navigation::RouteKmlWriter writer; + std::vector points; + std::string kml; +}; +static bool write(void* context, const char* bytes, size_t count) +{ + static_cast(context)->kml.append(bytes, count); + return true; +} +static bool point(void* context, tmap::GeoPoint value) +{ + auto& geometry = *static_cast(context); + geometry.points.push_back(value); + return geometry.writer.point(value); +} +static tmap::Result finish(navigation::RouteCalculation& calculation) +{ + auto result = tmap::Result::More; + for (unsigned step = 0; step < 100000 && navigation::RouteCalculation::pending(result); ++step) + result = calculation.step(4); + assert(!navigation::RouteCalculation::pending(result)); + return result; +} +int main(int argc, char** argv) +{ + assert(argc == 2); + File file; + std::ifstream input(argv[1], std::ios::binary); + assert(input); + file.data.assign(std::istreambuf_iterator(input), {}); + Memory memory; + navigation::RouteCalculation calculation(memory, tmap::MemoryPolicy::RequireExternal); + assert(memory.active.empty()); + for (const size_t busy : {size_t{0}, size_t{7}}) + { + file.busy_every = busy; + assert(calculation.begin(file, {0, 0}, {0, 20000}, tmap::TravelMode::Foot, {}) == tmap::Result::More); + assert(finish(calculation) == tmap::Result::Ok); + assert(calculation.legCount() == 3 && calculation.costUnits() > 30000); + Geometry output; + assert(output.writer.begin(write, &output, "Origin & ", {0, 0}, "Destination \"ridge\"", {0, 20000})); + assert(calculation.beginGeometry(point, &output) == tmap::Result::More); + assert(finish(calculation) == tmap::Result::Ok); + assert(output.writer.finish()); + const auto& geometry = output.points; + assert(geometry.size() >= 4 && geometry.front().latitude == 0 && geometry.front().longitude == 0); + assert(geometry.back().latitude == 0 && geometry.back().longitude == 20000); + assert(std::any_of(geometry.begin(), geometry.end(), [](const auto& p) + { return p.latitude == 10000; })); + std::ofstream saved(std::string(argv[1]) + ".kml", std::ios::binary); + saved << output.kml; + assert(saved.good()); + calculation.reset(); + assert(memory.active.empty() && memory.bytes == 0); + } + assert(calculation.begin(file, {0, 20000}, {0, 0}, tmap::TravelMode::Foot, {}) == tmap::Result::More); + assert(finish(calculation) == tmap::Result::NoPath); + assert(memory.active.empty()); + assert(calculation.begin(file, {0, 0}, {0, 20000}, tmap::TravelMode::Foot, {}) == tmap::Result::More); + for (unsigned i = 0; i < 10; ++i) calculation.step(1); + calculation.cancel(); + assert(calculation.phase() == navigation::RouteCalculation::Phase::Cancelled && memory.active.empty()); + assert(memory.peak <= 180 * 1024); +} diff --git a/tests/navigation/verify_saved_kml.py b/tests/navigation/verify_saved_kml.py new file mode 100644 index 00000000..de895060 --- /dev/null +++ b/tests/navigation/verify_saved_kml.py @@ -0,0 +1,23 @@ +"""Check that the saved route preserves endpoints and computed road geometry.""" +import sys +from xml.etree import ElementTree as ET + +ns = {"k": "http://www.opengis.net/kml/2.2"} +document = ET.parse(sys.argv[1]).getroot().find("k:Document", ns) +assert document.findtext("k:name", namespaces=ns) == 'Origin & - Destination "ridge"' +route = document.find("k:Placemark/k:LineString/k:coordinates", ns) +points = [tuple(map(float, value.split(","))) for value in route.text.split()] +assert points[0] == (0, 0) and points[-1] == (0.002, 0) +assert (0.001, 0.001) in points, "The saved navigation lost its road detour" +endpoints = {} +for marker in document.findall("k:Placemark", ns): + role = marker.findtext("k:ExtendedData/k:Data/k:value", namespaces=ns) + if role: + endpoints[role] = ( + marker.findtext("k:name", namespaces=ns), + marker.findtext("k:Point/k:coordinates", namespaces=ns), + ) +assert endpoints == { + "origin": ("Origin & ", "0.0000000,0.0000000"), + "destination": ('Destination "ridge"', "0.0020000,0.0000000"), +} diff --git a/tests/navigation_ui/CMakeLists.txt b/tests/navigation_ui/CMakeLists.txt new file mode 100644 index 00000000..a4d244d7 --- /dev/null +++ b/tests/navigation_ui/CMakeLists.txt @@ -0,0 +1,32 @@ +cmake_minimum_required(VERSION 3.20) +project(navigation_dialog_contract LANGUAGES C CXX) +set(CMAKE_CXX_STANDARD 17) +set(REPO "${CMAKE_CURRENT_LIST_DIR}/../..") +set(LVGL_DIR "" CACHE PATH "LVGL 9.4 source directory") +set(LVGL_LIBRARY "" CACHE FILEPATH "Optional compatible prebuilt LVGL library") +set(LV_BUILD_CONF_PATH "${REPO}/tests/contacts_card_view/lv_conf.h" CACHE PATH "" FORCE) +if(LVGL_LIBRARY) + add_library(lvgl STATIC IMPORTED) + set_target_properties(lvgl PROPERTIES IMPORTED_LOCATION "${LVGL_LIBRARY}") + target_include_directories(lvgl INTERFACE "${LVGL_DIR}" "${REPO}/tests/contacts_card_view") + target_compile_definitions(lvgl INTERFACE LV_CONF_INCLUDE_SIMPLE) +else() + set(CONFIG_LV_BUILD_DEMOS OFF CACHE BOOL "" FORCE) + set(CONFIG_LV_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + add_subdirectory("${LVGL_DIR}" lvgl) +endif() +add_executable(navigation_dialog_contract test_dialog.cpp + ${REPO}/modules/ui_shared/src/ui/screens/tracker/tracker_navigation_dialog.cpp + ${REPO}/modules/ui_shared/src/ui/components/two_pane_styles.cpp) +target_include_directories(navigation_dialog_contract PRIVATE + ${REPO}/modules/ui_shared/include ${REPO}/modules/core_sys/include ${REPO}/modules/core_gps/include) +# Select the device port declarations while exercising UI with host port fakes. +target_compile_definitions(navigation_dialog_contract PRIVATE ARDUINO_ARCH_ESP32) +target_link_libraries(navigation_dialog_contract PRIVATE lvgl) +if(MSVC) + target_compile_options(navigation_dialog_contract PRIVATE /utf-8 /UNDEBUG) +else() + target_compile_options(navigation_dialog_contract PRIVATE -UNDEBUG) +endif() +enable_testing() +add_test(NAME navigation_dialog_contract COMMAND navigation_dialog_contract) diff --git a/tests/navigation_ui/test_dialog.cpp b/tests/navigation_ui/test_dialog.cpp new file mode 100644 index 00000000..83c244ed --- /dev/null +++ b/tests/navigation_ui/test_dialog.cpp @@ -0,0 +1,184 @@ +#include "platform/ui/gps_runtime.h" +#include "platform/ui/navigation_runtime.h" +#include "ui/app_runtime.h" +#include "ui/components/map_poi_search.h" +#include "ui/localization.h" +#include "ui/page/page_profile.h" +#include "ui/screens/tracker/tracker_navigation_dialog.h" +#include +#include +#include +#include +#include +#include + +namespace nav = platform::ui::navigation; +namespace dialog = tracker::ui::navigation_dialog; +static nav::Request requested; +static nav::Snapshot response; +static gps::GpsState fix; +static unsigned submitted = 0, leases = 0; +static ui::components::map_poi_search::Selection selection = nullptr; +static void* selection_context = nullptr; +static std::string saved_path; + +namespace platform::ui::navigation +{ +bool supported() { return true; } +bool submit(const Request& request) +{ + requested = request; + ++submitted; + response = {}; + response.status = Status::Calculating; + response.revision = 1; + return true; +} +bool poll(Snapshot& snapshot) +{ + if (snapshot.revision == response.revision) return false; + snapshot = response; + return true; +} +bool save() +{ + assert(response.status == Status::Ready); + response.status = Status::Saved; + std::strcpy(response.path, "/routes/navigation/nav-origin-ridge-12345678.kml"); + ++response.revision; + return true; +} +void cancel() { response = {}; } +} // namespace platform::ui::navigation +namespace platform::ui::gps +{ +GpsState get_data() { return fix; } +void acquire_power_lease(const char*) { ++leases; } +void release_power_lease(const char*) +{ + assert(leases); + --leases; +} +} // namespace platform::ui::gps +namespace ui::components::map_poi_search +{ +bool open(lv_obj_t*, double, double, Selection callback, void* context) +{ + selection = callback; + selection_context = context; + return true; +} +void close() { selection = nullptr; } +} // namespace ui::components::map_poi_search +namespace ui::i18n +{ +const char* tr(const char* key) { return key; } +void set_label_text(lv_obj_t* object, const char* text) { lv_label_set_text(object, text); } +void set_label_text_raw(lv_obj_t* object, const char* text) { lv_label_set_text(object, text); } +void set_content_label_text_raw(lv_obj_t* object, const char* text) { lv_label_set_text(object, text); } +std::string format(const char* key, ...) +{ + char text[200]; + va_list args; + va_start(args, key); + std::vsnprintf(text, sizeof(text), key, args); + va_end(args); + return text; +} +} // namespace ui::i18n +namespace ui::page_profile +{ +const PageLayoutProfile& current() +{ + static const auto profile = [] + { PageLayoutProfile value; value.large_touch_hitbox = true; return value; }(); + return profile; +} +} // namespace ui::page_profile +void set_default_group(lv_group_t* group) { lv_group_set_default(group); } + +static lv_obj_t* find(lv_obj_t* root, const char* text) +{ + if (lv_obj_check_type(root, &lv_label_class) && std::strcmp(lv_label_get_text(root), text) == 0) return root; + for (uint32_t index = 0; index < lv_obj_get_child_count(root); ++index) + if (auto* result = find(lv_obj_get_child(root, index), text)) return result; + return nullptr; +} +static void click(const char* text) +{ + auto* label = find(lv_screen_active(), text); + assert(label); + auto* button = lv_obj_get_parent(label); + assert(lv_obj_check_type(button, &lv_button_class) && !lv_obj_has_state(button, LV_STATE_DISABLED)); + lv_obj_send_event(button, LV_EVENT_CLICKED, nullptr); +} +static void choose(const char* label, const char* name, int32_t latitude, int32_t longitude) +{ + click(label); + assert(selection); + platform::ui::map_search::Result result{}; + result.latitude_e7 = latitude; + result.longitude_e7 = longitude; + std::snprintf(result.name, sizeof(result.name), "%s", name); + selection(result, selection_context); + ui::components::map_poi_search::close(); +} +static void tick() +{ + lv_tick_inc(200); + lv_timer_handler(); +} +static void completed(const char* path, void*) { saved_path = path; } +int main() +{ + lv_init(); + auto* display = lv_display_create(320, 240); + std::vector pixels(320 * 240); + lv_display_set_buffers(display, pixels.data(), nullptr, pixels.size() * 2, LV_DISPLAY_RENDER_MODE_FULL); + lv_display_set_flush_cb(display, [](lv_display_t* d, const lv_area_t*, uint8_t*) + { lv_display_flush_ready(d); }); + auto* original_group = lv_group_create(); + lv_group_set_default(original_group); + assert(dialog::open(lv_screen_active(), completed)); + click("Calculate route"); + assert(submitted == 0); + choose("Choose destination", "Ridge", 312010000, 1214100000); + click("Calculate route"); + assert(submitted == 0 && find(lv_screen_active(), "No GPS fix")); + fix.valid = true; + fix.lat = 31.2; + fix.lng = 121.4; + click("Calculate route"); + assert(submitted == 1 && requested.origin.latitude_e7 == 312000000 && requested.origin.longitude_e7 == 1214000000); + assert(requested.destination.latitude_e7 == 312010000 && requested.destination.longitude_e7 == 1214100000); + response.status = nav::Status::Ready; + response.distance_m = 1200; + ++response.revision; + tick(); + assert(find(lv_screen_active(), "Route ready: 1.2 km")); + click("Save and preview"); + assert(!dialog::is_open() && saved_path == "/routes/navigation/nav-origin-ridge-12345678.kml"); + assert(lv_group_get_default() == original_group && leases == 0); + + fix = {}; + assert(dialog::open(lv_screen_active(), completed)); + choose("Current location", "Park", 300000000, 1200000000); + choose("Choose destination", "Summit", 301000000, 1201000000); + click("Calculate route"); + assert(submitted == 2 && requested.origin.latitude_e7 == 300000000 && std::strcmp(requested.origin.name, "Park") == 0); + auto* cancel = lv_obj_get_parent(find(lv_screen_active(), "Cancel")); + auto* overlay = lv_obj_get_parent(lv_obj_get_parent(cancel)); + auto* body = lv_obj_get_child(overlay, 1); + lv_obj_update_layout(overlay); + lv_area_t before{}, after{}; + lv_obj_get_coords(cancel, &before); + lv_obj_scroll_to_y(body, 10000, LV_ANIM_OFF); + lv_obj_update_layout(overlay); + lv_obj_get_coords(cancel, &after); + assert(std::memcmp(&before, &after, sizeof(before)) == 0 && before.y1 >= 0 && before.y2 < 240); + click("Cancel"); + assert(!dialog::is_open() && leases == 0 && lv_group_get_default() == original_group); + lv_group_delete(original_group); + lv_display_delete(display); + std::puts("Navigation dialog: current GPS, searched endpoints, ready/save/preview, fixed touch cancel PASS"); +} diff --git a/tests/tmap/CMakeLists.txt b/tests/tmap/CMakeLists.txt index a1037441..3b16d430 100644 --- a/tests/tmap/CMakeLists.txt +++ b/tests/tmap/CMakeLists.txt @@ -53,3 +53,4 @@ else() target_compile_options(tmap_source_probe PRIVATE -Wall -Wextra -Werror -UNDEBUG) target_compile_definitions(tmap_source_probe PRIVATE _FILE_OFFSET_BITS=64) endif() +add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../navigation" navigation)