WIP: checkpoint geocaching before main integration

This commit is contained in:
Trail Mate Dev
2026-09-24 08:47:53 +08:00
parent d7a2f35328
commit 3d65ece83d
244 changed files with 25291 additions and 126 deletions
@@ -18,8 +18,10 @@
#if defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) || \
defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4)
#define TRAIL_MATE_ENABLE_CALCULATOR_APP 1
#define TRAIL_MATE_ENABLE_GEOCACHING_APP 1
#else
#define TRAIL_MATE_ENABLE_CALCULATOR_APP 0
#define TRAIL_MATE_ENABLE_GEOCACHING_APP 0
#endif
#if TRAIL_MATE_USE_MONO_SCREEN_240X320
#include "ui/mono/screens/screen_240x320/screen_app.h"
@@ -46,6 +48,9 @@
#if TRAIL_MATE_ENABLE_CALCULATOR_APP
#include "ui/screens/calculator/calculator_page_shell.h"
#endif
#if TRAIL_MATE_ENABLE_GEOCACHING_APP
#include "ui/screens/geocaching/geocaching_page_shell.h"
#endif
#include "ui/screens/contacts/contacts_page_shell.h"
#include "ui/screens/energy_sweep/energy_sweep_page_shell.h"
#include "ui/screens/extensions/extensions_page_shell.h"
@@ -84,6 +89,7 @@ extern "C"
{
extern const lv_image_dsc_t Chat;
extern const lv_image_dsc_t calc;
extern const lv_image_dsc_t geocaching;
extern const lv_image_dsc_t gps_icon;
extern const lv_image_dsc_t Satellite;
extern const lv_image_dsc_t contact;
@@ -222,6 +228,12 @@ ui::CallbackAppScreen s_tracker_app("tracker", "Tracker", CATALOG_ICON(tracker_i
tracker::ui::shell::enter,
tracker::ui::shell::exit,
&s_menu_host);
#if TRAIL_MATE_ENABLE_GEOCACHING_APP
ui::CallbackAppScreen s_geocaching_app("geocaching", "Geocaching", CATALOG_ICON(geocaching),
::geocaching::ui::shell::enter,
::geocaching::ui::shell::exit,
&s_menu_host);
#endif
#if TRAIL_MATE_ENABLE_CALCULATOR_APP
ui::CallbackAppScreen s_calculator_app("calculator", "Calculator", CATALOG_ICON(calc),
calculator::ui::shell::enter,
@@ -476,6 +488,9 @@ AppCatalog build(const FeatureFlags& flags)
add(&s_team_app);
#endif
}
#if TRAIL_MATE_ENABLE_GEOCACHING_APP
add(&s_geocaching_app);
#endif
#if TRAIL_MATE_ENABLE_CALCULATOR_APP
add(&s_calculator_app);
#endif
@@ -0,0 +1,859 @@
#include "ui/screens/geocaching/geocaching_page_shell.h"
#include "ui/app_runtime.h"
#include "ui/components/two_pane_styles.h"
#include "ui/page/page_profile.h"
#include "ui/screens/gps/gps_page_runtime.h"
#include "ui/widgets/top_bar.h"
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <new>
namespace geocaching::ui::shell
{
namespace
{
namespace styles = ::ui::components::two_pane_styles;
using Section = ::ui::geocaching::Section;
constexpr size_t kVisibleRows = 4;
struct Editor
{
::ui::geocaching::DraftInput input;
std::array<lv_obj_t*, 7> fields{};
lv_obj_t* container = nullptr;
lv_obj_t* publication_notice = nullptr;
std::array<uint8_t, 64> publication_author{};
uint32_t previous_revision = 0, publication_revision = 0;
bool public_confirmation = false;
bool dirty = false, saving = false, confirming = false, loading = false;
};
struct PageState
{
Editor* editor = nullptr;
::ui::geocaching::Snapshot snapshot;
::ui::widgets::TopBar topbar;
const ::ui::page::Host* host = nullptr;
lv_obj_t *root = nullptr, *list = nullptr, *status = nullptr;
lv_obj_t *refresh = nullptr, *previous = nullptr, *next = nullptr, *range = nullptr;
std::array<lv_obj_t*, 3> tabs{};
std::array<lv_obj_t*, kVisibleRows> rows{};
std::array<std::array<uint8_t, 32>, kVisibleRows> row_ids{};
lv_group_t *group = nullptr, *previous_group = nullptr;
lv_timer_t* timer = nullptr;
::ui::geocaching::Source* rendered_source = nullptr;
Section section = Section::Discover, rendered_section = Section::Discover;
size_t offset = 0, rendered_offset = 0, row_count = 0, window = kVisibleRows;
size_t detail_index = 0;
uint64_t detail_generation = 0;
std::array<uint8_t, 32> detail_id{}, detail_hash{};
bool valid = false, details = false;
};
static_assert(sizeof(PageState) <= 640, "Page state must not contain an entire result list");
::ui::geocaching::Source* source = nullptr;
PageState* page = nullptr;
struct MapVisit
{
::gps::ui::runtime::MapTarget target;
std::array<uint8_t, 32> selected_id{};
uint64_t overlay_generation = 0;
::ui::page::Host host;
lv_obj_t* parent = nullptr;
};
MapVisit* map_visit = nullptr;
void refreshView();
void closeDetails();
void showDetails(const ::ui::geocaching::Item& item, size_t index);
void openEditor(const ::ui::geocaching::Item* item);
void editorBack();
void saveEditor();
void closeEditor();
void previewPublication();
void returnFromMap(void*)
{
if (!map_visit) return;
::gps::ui::runtime::exit(map_visit->parent);
delete map_visit;
map_visit = nullptr;
if (!page) return;
lv_obj_remove_flag(page->root, LV_OBJ_FLAG_HIDDEN);
set_default_group(page->group);
if (page->timer) lv_timer_resume(page->timer);
refreshView();
if (page->group) lv_group_focus_obj(page->topbar.back_btn);
}
bool openMap(const ::ui::geocaching::Item& item)
{
if (!page || map_visit || !item.downloaded) return false;
map_visit = new (std::nothrow) MapVisit;
if (!map_visit) return false;
map_visit->target.latitude_e7 = item.latitude_e7;
map_visit->target.longitude_e7 = item.longitude_e7;
map_visit->selected_id = item.id;
map_visit->target.overlay_context = map_visit;
map_visit->target.append_overlays = [](void* context, ::ui::map::MapOverlaySnapshot& overlays)
{
if (!source) return;
auto& visit = *static_cast<MapVisit*>(context);
::ui::geocaching::Snapshot saved;
source->snapshot(Section::Downloaded, saved);
visit.overlay_generation = saved.generation;
::ui::geocaching::Item cached;
for (size_t index = 0; index < saved.count; ++index)
{
if (overlays.item_count == ::ui::map::MapOverlaySnapshot::kMaxItems)
{
overlays.truncated = true;
break;
}
if (!source->item(Section::Downloaded, index, saved.generation, cached)) break;
if (!cached.downloaded || cached.id == visit.selected_id) continue;
auto& marker = overlays.items[overlays.item_count++];
marker = ::ui::map::MapOverlayItem{};
marker.kind = ::ui::map::MapOverlayKind::Geocache;
marker.stable_id = static_cast<uint32_t>(index + 1);
marker.point.valid = true;
marker.point.lat = cached.latitude_e7 / 10000000.0;
marker.point.lon = cached.longitude_e7 / 10000000.0;
::ui::copyText(marker.label, cached.name.data());
}
};
map_visit->target.select_overlay = [](void* context, uint32_t id)
{
if (!page || !source) return;
const auto& visit = *static_cast<MapVisit*>(context);
if (id)
{
::ui::geocaching::Item selected;
if (!source->item(Section::Downloaded, id - 1, visit.overlay_generation, selected) || !selected.downloaded) return;
page->section = Section::Downloaded;
page->snapshot.generation = visit.overlay_generation;
showDetails(selected, id - 1);
}
lv_async_call_cancel(returnFromMap, nullptr);
lv_async_call(returnFromMap, nullptr);
};
std::snprintf(map_visit->target.name, sizeof(map_visit->target.name), "%s", item.name.data());
map_visit->parent = lv_obj_get_parent(page->root);
map_visit->host.request_exit = [](void*)
{ lv_async_call_cancel(returnFromMap, nullptr); lv_async_call(returnFromMap, nullptr); };
lv_obj_add_flag(page->root, LV_OBJ_FLAG_HIDDEN);
if (page->timer) lv_timer_pause(page->timer);
if (::gps::ui::runtime::enter_target(&map_visit->host, map_visit->parent, map_visit->target)) return true;
delete map_visit;
map_visit = nullptr;
lv_obj_remove_flag(page->root, LV_OBJ_FLAG_HIDDEN);
if (page->timer) lv_timer_resume(page->timer);
set_default_group(page->group);
return false;
}
void keyEvent(lv_event_t* event)
{
if (!page) return;
const auto key = lv_event_get_key(event);
if (key == LV_KEY_ESC)
{
lv_event_stop_processing(event);
if (page->editor) editorBack();
else if (page->details) closeDetails();
else if (page->host) ::ui::page::request_exit(page->host);
}
else if (page->editor && page->group && lv_group_get_editing(page->group)) return;
else if (page->details && (key == LV_KEY_DOWN || key == LV_KEY_UP))
lv_obj_scroll_by(page->list, 0, key == LV_KEY_DOWN ? -28 : 28, LV_ANIM_OFF);
else if (page->group && (key == LV_KEY_DOWN || key == LV_KEY_RIGHT)) lv_group_focus_next(page->group);
else if (page->group && (key == LV_KEY_UP || key == LV_KEY_LEFT)) lv_group_focus_prev(page->group);
}
void addFocusable(lv_obj_t* object)
{
if (page->group) lv_group_add_obj(page->group, object);
lv_obj_add_event_cb(object, keyEvent, LV_EVENT_KEY, nullptr);
}
void setEnabled(lv_obj_t* object, bool enabled)
{
if (page->group) lv_group_remove_obj(object);
if (enabled)
{
lv_obj_remove_state(object, LV_STATE_DISABLED);
if (page->group) lv_group_add_obj(page->group, object);
}
else lv_obj_add_state(object, LV_STATE_DISABLED);
}
lv_obj_t* button(lv_obj_t* parent, const char* text, lv_coord_t height)
{
auto* object = lv_button_create(parent);
lv_obj_remove_style_all(object);
styles::apply_btn_filter(object);
lv_obj_set_style_pad_hor(object, 6, 0);
lv_obj_set_style_pad_ver(object, 2, 0);
lv_obj_set_style_opa(object, LV_OPA_50, LV_STATE_DISABLED);
lv_obj_set_height(object, height);
auto* label = lv_label_create(object);
lv_label_set_text(label, text);
lv_obj_center(label);
addFocusable(object);
return object;
}
void refreshView()
{
if (!page) return;
if (page->editor)
{
auto& editor = *page->editor;
if (editor.loading)
{
::ui::geocaching::Item item;
std::copy(editor.input.id.begin(), editor.input.id.end(), item.id.begin());
openEditor(&item);
return;
}
if (!editor.confirming && !editor.public_confirmation && !editor.saving && editor.input.generation)
{
lv_obj_remove_flag(page->previous, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text(lv_obj_get_child(page->previous, 0), "Publish");
if (lv_obj_has_state(page->previous, LV_STATE_DISABLED) != editor.dirty) setEnabled(page->previous, !editor.dirty);
}
if (editor.saving && source)
{
const auto status = source->draftSaveStatus(editor.input.id, editor.input.generation);
if (status == ::ui::geocaching::DraftSaveStatus::Saved)
{
closeEditor();
return;
}
if (status == ::ui::geocaching::DraftSaveStatus::Failed)
{
editor.saving = false;
for (auto* field : editor.fields) lv_obj_remove_state(field, LV_STATE_DISABLED);
lv_obj_remove_state(editor.container, LV_STATE_DISABLED);
lv_label_set_text(page->status, "Save failed; edits retained");
setEnabled(page->next, true);
}
}
return;
}
if (page->details)
{
if (!source) return;
::ui::geocaching::Snapshot current;
source->snapshot(page->section, current);
if (current.generation == page->detail_generation) return;
::ui::geocaching::Item item;
const bool same = source->item(page->section, page->detail_index, current.generation, item) &&
item.id == page->detail_id && item.revision_hash == page->detail_hash;
lv_label_set_text(page->status, current.status.data());
if (same) lv_label_set_text(lv_obj_get_child(page->list, 1), item.detail.data());
lv_label_set_text(lv_obj_get_child(page->next, 0), same && item.downloaded ? "Map" : "Download");
setEnabled(page->next, same && (item.can_download || item.downloaded));
page->detail_generation = current.generation;
return;
}
auto& p = *page;
const auto old_generation = p.snapshot.generation;
auto* focused = p.group ? lv_group_get_focused(p.group) : nullptr;
std::array<uint8_t, 32> focused_id{};
bool focused_row = false;
for (size_t i = 0; i < p.row_count; ++i)
if (focused == p.rows[i])
{
focused_id = p.row_ids[i];
focused_row = true;
break;
}
p.snapshot = {};
if (source) source->snapshot(p.section, p.snapshot);
else std::snprintf(p.snapshot.status.data(), p.snapshot.status.size(), "Geocaching service unavailable");
if (p.offset >= p.snapshot.count) p.offset = p.snapshot.count ? ((p.snapshot.count - 1) / p.window) * p.window : 0;
if (p.valid && p.rendered_source == source && p.rendered_section == p.section &&
p.rendered_offset == p.offset && old_generation == p.snapshot.generation) return;
p.valid = true;
p.rendered_source = source;
p.rendered_section = p.section;
p.rendered_offset = p.offset;
for (size_t i = 0; i < p.tabs.size(); ++i)
{
if (i == static_cast<size_t>(p.section)) lv_obj_add_state(p.tabs[i], LV_STATE_CHECKED);
else lv_obj_remove_state(p.tabs[i], LV_STATE_CHECKED);
}
lv_label_set_text(p.status, p.snapshot.status.data());
lv_obj_clean(p.list);
p.rows.fill(nullptr);
p.row_count = 0;
::ui::geocaching::Item item;
while (p.row_count < p.window && p.offset + p.row_count < p.snapshot.count)
{
const size_t i = p.row_count;
if (!source || !source->item(p.section, p.offset + i, p.snapshot.generation, item))
{
p.valid = false;
break;
}
auto* row = button(p.list, item.name.data(), ::ui::page_profile::current().list_item_height);
lv_obj_set_width(row, LV_PCT(100));
auto* label = lv_obj_get_child(row, 0);
lv_obj_set_width(label, LV_PCT(100));
lv_label_set_long_mode(label, LV_LABEL_LONG_DOT);
if (item.is_draft && item.publication_revision)
lv_label_set_text_fmt(label, "v%lu %s | %s", static_cast<unsigned long>(item.publication_revision),
item.publication_confirmed ? "accepted" : "unconfirmed", item.name.data());
p.rows[i] = row;
p.row_ids[i] = item.id;
++p.row_count;
lv_obj_add_event_cb(
row, [](lv_event_t* event)
{
if (!page || !source) return;
const auto index = reinterpret_cast<uintptr_t>(lv_event_get_user_data(event));
::ui::geocaching::Item selected;
if (index < page->row_count &&
source->item(page->section, page->offset + index, page->snapshot.generation, selected) &&
selected.id == page->row_ids[index])
{ const auto row = page->offset + index; source->open(selected, page->snapshot.generation); if (page) showDetails(selected, row); }
else { page->valid = false; refreshView(); } },
LV_EVENT_CLICKED, reinterpret_cast<void*>(i));
}
if (!p.row_count)
{
auto* empty = lv_label_create(p.list);
lv_label_set_text(empty, source ? "No caches in this view" : "No cached items");
styles::apply_label_muted(empty);
lv_obj_set_width(empty, LV_PCT(100));
lv_obj_set_style_text_align(empty, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_pad_top(empty, 12, 0);
}
lv_label_set_text_fmt(p.range, "%lu-%lu / %lu",
static_cast<unsigned long>(p.row_count ? p.offset + 1 : 0),
static_cast<unsigned long>(p.offset + p.row_count), static_cast<unsigned long>(p.snapshot.count));
setEnabled(p.refresh, p.section == Section::Published ? p.snapshot.can_create : p.snapshot.can_refresh);
lv_label_set_text(lv_obj_get_child(p.refresh, 0), p.section == Section::Published ? "New" : "Refresh");
setEnabled(p.previous, p.offset != 0);
setEnabled(p.next, (p.offset + p.row_count < p.snapshot.count || p.snapshot.has_more) && p.row_count != 0 && p.valid);
if (p.group)
{
if (focused_row)
{
auto* restore = p.tabs[static_cast<size_t>(p.section)];
for (size_t i = 0; i < p.row_count; ++i)
if (p.row_ids[i] == focused_id) restore = p.rows[i];
lv_group_focus_obj(restore);
}
else if (focused && !lv_obj_has_state(focused, LV_STATE_DISABLED)) lv_group_focus_obj(focused);
else lv_group_focus_obj(p.tabs[static_cast<size_t>(p.section)]);
}
}
void closeDetails()
{
if (!page) return;
page->details = false;
page->valid = false;
lv_obj_remove_flag(page->previous, LV_OBJ_FLAG_HIDDEN);
lv_obj_set_width(page->next, ::ui::page_profile::current().control_button_height);
lv_label_set_text(lv_obj_get_child(page->next, 0), LV_SYMBOL_RIGHT);
lv_obj_remove_flag(page->list, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_scroll_to_y(page->list, 0, LV_ANIM_OFF);
refreshView();
}
void showDetails(const ::ui::geocaching::Item& item, size_t index)
{
if (item.is_draft)
{
openEditor(&item);
return;
}
auto& p = *page;
p.details = true;
p.detail_index = index;
p.detail_generation = p.snapshot.generation;
p.detail_id = item.id;
p.detail_hash = item.revision_hash;
lv_obj_clean(p.list);
p.rows.fill(nullptr);
p.row_count = 0;
lv_obj_add_flag(p.list, LV_OBJ_FLAG_SCROLLABLE);
auto* title = lv_label_create(p.list);
lv_obj_set_width(title, LV_PCT(100));
lv_label_set_text(title, item.name.data());
styles::apply_label_primary(title);
auto* body = lv_label_create(p.list);
lv_obj_set_width(body, LV_PCT(100));
lv_label_set_text(body, item.detail.data());
styles::apply_label_muted(body);
lv_label_set_text(p.range, "Preview");
lv_label_set_text(lv_obj_get_child(p.refresh, 0), "Back");
setEnabled(p.refresh, true);
setEnabled(p.previous, false);
lv_obj_add_flag(p.previous, LV_OBJ_FLAG_HIDDEN);
lv_obj_set_width(p.next, ::ui::page_profile::current().control_button_min_width);
lv_label_set_text(lv_obj_get_child(p.next, 0), item.downloaded ? "Map" : "Download");
setEnabled(p.next, item.can_download || item.downloaded);
if (p.group) lv_group_focus_obj(p.topbar.back_btn);
}
bool parseDecimal(const char* text, int32_t& out)
{
if (!text || !*text) return false;
const bool negative = *text == '-';
if (negative) ++text;
if (*text < '0' || *text > '9') return false;
int64_t whole = 0, fraction = 0, scale = 1000000;
while (*text >= '0' && *text <= '9')
{
whole = whole * 10 + (*text++ - '0');
if (whole > 180) return false;
}
if (*text == '.')
{
++text;
if (*text < '0' || *text > '9') return false;
while (*text >= '0' && *text <= '9')
{
if (!scale) return false;
fraction += (*text++ - '0') * scale;
scale /= 10;
}
}
if (*text) return false;
const auto value = whole * 10000000 + fraction;
if (value > 1800000000) return false;
out = static_cast<int32_t>(negative ? -value : value);
return true;
}
void closeEditor()
{
if (!page || !page->editor) return;
if (page->editor->loading && source) source->cancelDraftRead(page->editor->input.id);
delete page->editor;
page->editor = nullptr;
for (auto* tab : page->tabs) setEnabled(tab, true);
lv_obj_set_width(page->previous, ::ui::page_profile::current().control_button_height);
lv_label_set_text(lv_obj_get_child(page->previous, 0), LV_SYMBOL_LEFT);
closeDetails();
}
void editorBack()
{
if (!page || !page->editor) return;
auto& editor = *page->editor;
if (editor.public_confirmation)
{
editor.public_confirmation = false;
if (editor.publication_notice) lv_obj_delete(editor.publication_notice);
editor.publication_notice = nullptr;
for (auto* field : editor.fields) lv_obj_remove_state(field, LV_STATE_DISABLED);
lv_obj_remove_state(editor.container, LV_STATE_DISABLED);
lv_label_set_text(lv_obj_get_child(page->refresh, 0), "Back");
lv_label_set_text(lv_obj_get_child(page->next, 0), "Save");
lv_label_set_text(page->status, "Editing local draft");
refreshView();
return;
}
if (editor.saving)
{
lv_label_set_text(page->status, "Saving; please wait");
return;
}
if (!editor.dirty)
{
closeEditor();
return;
}
editor.confirming = !editor.confirming;
lv_label_set_text(lv_obj_get_child(page->previous, 0), "Discard");
lv_label_set_text(page->status, editor.confirming ? "Unsaved edits: save, discard or keep editing" : "Editing local draft");
lv_label_set_text(lv_obj_get_child(page->refresh, 0), editor.confirming ? "Keep" : "Back");
if (editor.confirming) lv_obj_remove_flag(page->previous, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(page->previous, LV_OBJ_FLAG_HIDDEN);
setEnabled(page->previous, editor.confirming);
}
void previewPublication()
{
if (!page || !page->editor || !source) return;
auto& editor = *page->editor;
if (editor.dirty || editor.saving || !editor.input.generation ||
!source->publicationAuthor(editor.input.id, editor.input.generation, editor.publication_author, &editor.previous_revision, &editor.publication_revision))
{
lv_label_set_text(page->status, "Save location/name; check author, clock and directory");
return;
}
editor.public_confirmation = true;
char author[129];
constexpr char hex[] = "0123456789abcdef";
for (size_t i = 0; i < editor.publication_author.size(); ++i)
{
author[i * 2] = hex[editor.publication_author[i] >> 4];
author[i * 2 + 1] = hex[editor.publication_author[i] & 15];
}
author[128] = 0;
editor.publication_notice = lv_label_create(page->list);
lv_obj_set_width(editor.publication_notice, LV_PCT(100));
lv_label_set_long_mode(editor.publication_notice, LV_LABEL_LONG_WRAP);
if (editor.previous_revision)
lv_label_set_text_fmt(editor.publication_notice, "Publish this cache publicly on Reticulum?\nv%lu -> v%lu\nAuthor public identity:\n%s",
static_cast<unsigned long>(editor.previous_revision), static_cast<unsigned long>(editor.publication_revision), author);
else
lv_label_set_text_fmt(editor.publication_notice, "Publish this cache publicly on Reticulum?\nFirst publication: v%lu\nAuthor public identity:\n%s",
static_cast<unsigned long>(editor.publication_revision), author);
styles::apply_label_primary(editor.publication_notice);
lv_obj_move_to_index(editor.publication_notice, 0);
for (auto* field : editor.fields) lv_obj_add_state(field, LV_STATE_DISABLED);
lv_obj_add_state(editor.container, LV_STATE_DISABLED);
lv_obj_scroll_to_y(page->list, 0, LV_ANIM_OFF);
lv_label_set_text(page->status, "Review author and saved fields before publishing");
lv_label_set_text(lv_obj_get_child(page->refresh, 0), "Cancel");
lv_label_set_text(lv_obj_get_child(page->next, 0), "Publish");
setEnabled(page->previous, false);
lv_obj_add_flag(page->previous, LV_OBJ_FLAG_HIDDEN);
if (page->group) lv_group_focus_obj(page->refresh);
}
void saveEditor()
{
if (!page || !page->editor || !source || page->editor->saving || page->editor->loading) return;
auto& editor = *page->editor;
if (editor.public_confirmation)
{
if (source->publishDraft(editor.input.id, editor.input.generation, editor.publication_author, editor.publication_revision)) closeEditor();
else lv_label_set_text(page->status, "Publication not started; review identity and draft again");
return;
}
auto& input = editor.input;
const char* lat = lv_textarea_get_text(editor.fields[1]);
const char* lon = lv_textarea_get_text(editor.fields[2]);
input.has_coordinates = *lat || *lon;
int32_t difficulty = 0, terrain = 0;
if ((input.has_coordinates && (!parseDecimal(lat, input.latitude_e7) || !parseDecimal(lon, input.longitude_e7) ||
input.latitude_e7 < -900000000 || input.latitude_e7 > 900000000 || input.longitude_e7 >= 1800000000)) ||
!parseDecimal(lv_textarea_get_text(editor.fields[5]), difficulty) || difficulty < 10000000 || difficulty > 50000000 || difficulty % 5000000 ||
!parseDecimal(lv_textarea_get_text(editor.fields[6]), terrain) || terrain < 10000000 || terrain > 50000000 || terrain % 5000000)
{
lv_label_set_text(page->status, "Check coordinates and 1-5 ratings (step 0.5)");
return;
}
input.difficulty_x2 = static_cast<uint8_t>(difficulty / 5000000);
input.terrain_x2 = static_cast<uint8_t>(terrain / 5000000);
input.container_size = static_cast<uint8_t>(lv_dropdown_get_selected(editor.container));
input.name = lv_textarea_get_text(editor.fields[0]);
input.description = lv_textarea_get_text(editor.fields[3]);
input.hint = lv_textarea_get_text(editor.fields[4]);
if (!source->saveDraft(input))
{
lv_label_set_text(page->status, "Cannot save now; edits retained");
return;
}
editor.saving = true;
for (auto* field : editor.fields) lv_obj_add_state(field, LV_STATE_DISABLED);
lv_obj_add_state(editor.container, LV_STATE_DISABLED);
setEnabled(page->next, false);
setEnabled(page->previous, false);
lv_label_set_text(page->status, "Saving local draft...");
}
void openEditor(const ::ui::geocaching::Item* item)
{
if (!page || !source || (page->editor && !page->editor->loading)) return;
if (!page->editor) page->editor = new (std::nothrow) Editor;
if (!page->editor) return;
auto& editor = *page->editor;
if (!editor.loading)
{
page->details = false;
page->row_count = 0;
page->rows.fill(nullptr);
lv_obj_clean(page->list);
lv_obj_add_flag(page->list, LV_OBJ_FLAG_SCROLLABLE);
const char* names[] = {"Name", "Latitude (blank if unset)", "Longitude", "Description", "Hint", "Difficulty (1-5)", "Terrain (1-5)"};
const uint32_t limits[] = {96, 12, 13, 2048, 512, 3, 3};
for (size_t i = 0; i < editor.fields.size(); ++i)
{
auto* label = lv_label_create(page->list);
lv_label_set_text(label, names[i]);
styles::apply_label_primary(label);
auto* field = editor.fields[i] = lv_textarea_create(page->list);
lv_obj_set_width(field, LV_PCT(100));
lv_textarea_set_one_line(field, i != 3 && i != 4);
lv_obj_set_height(field, i == 3 || i == 4 ? 64 : 32);
lv_textarea_set_max_length(field, limits[i]);
lv_textarea_set_text(field, i == 5 || i == 6 ? "1.0" : "");
lv_obj_set_style_bg_color(field, lv_color_hex(styles::kSidePanelBg), 0);
lv_obj_set_style_text_color(field, lv_color_hex(styles::kTextPrimary), 0);
lv_obj_set_style_border_color(field, lv_color_hex(styles::kBorder), 0);
lv_obj_set_style_border_color(field, lv_color_hex(styles::kAccent), LV_STATE_FOCUSED);
lv_obj_set_style_outline_color(field, lv_color_hex(styles::kAccent), LV_STATE_FOCUSED);
addFocusable(field);
lv_obj_add_event_cb(
field, [](lv_event_t*)
{ if (page && page->editor) page->editor->dirty = true; },
LV_EVENT_VALUE_CHANGED, nullptr);
}
auto* label = lv_label_create(page->list);
lv_label_set_text(label, "Container");
editor.container = lv_dropdown_create(page->list);
lv_dropdown_set_options(editor.container, "Unspecified\nMicro\nSmall\nRegular\nLarge\nOther");
lv_obj_set_style_bg_color(editor.container, lv_color_hex(styles::kMainPanelBg), 0);
lv_obj_set_style_text_color(editor.container, lv_color_hex(styles::kTextPrimary), 0);
lv_obj_set_style_border_color(editor.container, lv_color_hex(styles::kAccent), LV_STATE_FOCUSED);
lv_obj_set_style_outline_color(editor.container, lv_color_hex(styles::kAccent), LV_STATE_FOCUSED);
lv_obj_add_event_cb(
editor.container, [](lv_event_t* event)
{
auto* list = lv_dropdown_get_list(lv_event_get_target_obj(event));
if (!list) return;
lv_obj_set_style_bg_color(list, lv_color_hex(styles::kMainPanelBg), 0);
lv_obj_set_style_text_color(list, lv_color_hex(styles::kTextPrimary), 0);
lv_obj_set_style_bg_color(list, lv_color_hex(styles::kAccent), LV_PART_SELECTED | LV_STATE_CHECKED); },
LV_EVENT_CLICKED, nullptr);
addFocusable(editor.container);
lv_obj_add_event_cb(
editor.container, [](lv_event_t*)
{ if (page && page->editor) page->editor->dirty = true; },
LV_EVENT_VALUE_CHANGED, nullptr);
}
if (item)
{
std::array<uint8_t, 16> id;
std::copy_n(item->id.data(), id.size(), id.data());
const auto result = source->readDraft(
id, [](const ::ui::geocaching::DraftInput& input, void* context)
{
auto& e = *static_cast<Editor*>(context);
e.input = input;
const std::string_view texts[] = {input.name, input.description, input.hint};
const size_t slots[] = {0, 3, 4};
for (size_t i = 0; i < 3; ++i)
{
auto remaining = texts[i];
while (!remaining.empty())
{
size_t count = std::min<size_t>(64, remaining.size());
if (count < remaining.size())
while (count && (static_cast<uint8_t>(remaining[count]) & 0xc0) == 0x80) --count;
char chunk[65];
std::memcpy(chunk, remaining.data(), count); chunk[count] = 0;
lv_textarea_add_text(e.fields[slots[i]], chunk);
remaining.remove_prefix(count);
}
}
char text[24];
if (input.has_coordinates)
{
std::snprintf(text, sizeof(text), "%.7f", input.latitude_e7 / 10000000.0); lv_textarea_set_text(e.fields[1], text);
std::snprintf(text, sizeof(text), "%.7f", input.longitude_e7 / 10000000.0); lv_textarea_set_text(e.fields[2], text);
}
std::snprintf(text, sizeof(text), "%.1f", input.difficulty_x2 / 2.0); lv_textarea_set_text(e.fields[5], text);
std::snprintf(text, sizeof(text), "%.1f", input.terrain_x2 / 2.0); lv_textarea_set_text(e.fields[6], text);
lv_dropdown_set_selected(e.container, input.container_size);
e.input.name = e.input.description = e.input.hint = {}; },
&editor);
if (result == ::ui::geocaching::DraftReadStatus::Pending)
{
editor.loading = true;
editor.input.id = id;
editor.dirty = false;
for (auto* field : editor.fields) setEnabled(field, false);
setEnabled(editor.container, false);
for (auto* tab : page->tabs) setEnabled(tab, false);
setEnabled(page->previous, false);
setEnabled(page->next, false);
setEnabled(page->refresh, true);
lv_label_set_text(lv_obj_get_child(page->refresh, 0), "Back");
lv_label_set_text(page->status, "Loading local draft...");
if (page->group) lv_group_focus_obj(page->refresh);
return;
}
if (result == ::ui::geocaching::DraftReadStatus::Failed)
{
closeEditor();
lv_label_set_text(page->status, "Cannot read local draft");
return;
}
}
editor.loading = false;
for (auto* field : editor.fields) setEnabled(field, true);
setEnabled(editor.container, true);
editor.dirty = !item;
for (auto* tab : page->tabs) setEnabled(tab, false);
lv_label_set_text(page->status, item && item->detail[0] ? item->detail.data() : "Editing local draft");
lv_label_set_text(page->range, "Draft");
lv_label_set_text(lv_obj_get_child(page->refresh, 0), "Back");
setEnabled(page->refresh, true);
lv_label_set_text(lv_obj_get_child(page->previous, 0), "Discard");
lv_obj_set_width(page->previous, ::ui::page_profile::current().control_button_min_width);
setEnabled(page->previous, false);
lv_obj_add_flag(page->previous, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text(lv_obj_get_child(page->next, 0), "Save");
lv_obj_set_width(page->next, ::ui::page_profile::current().control_button_min_width);
setEnabled(page->next, true);
if (page->group) lv_group_focus_obj(editor.fields[0]);
}
} // namespace
void bind(::ui::geocaching::Source* value)
{
if (value != source && page && page->editor && page->editor->loading) closeEditor();
source = value;
if (page) page->valid = false;
}
void enter(void* user_data, lv_obj_t* parent)
{
if (page || !parent) return;
page = new (std::nothrow) PageState;
if (!page)
{
if (user_data) ::ui::page::request_exit(static_cast<const ::ui::page::Host*>(user_data));
return;
}
auto& p = *page;
const auto& profile = ::ui::page_profile::current();
if (source) source->activate(true);
p.previous_group = lv_group_get_default();
p.group = lv_group_create();
set_default_group(nullptr);
p.host = static_cast<const ::ui::page::Host*>(user_data);
p.root = lv_obj_create(parent);
lv_obj_remove_style_all(p.root);
lv_obj_set_size(p.root, LV_PCT(100), LV_PCT(100));
lv_obj_set_flex_flow(p.root, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_bg_opa(p.root, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(p.root, lv_color_hex(styles::kSidePanelBg), 0);
lv_obj_set_style_text_color(p.root, lv_color_hex(styles::kTextPrimary), 0);
lv_obj_set_style_text_font(p.root, ::ui::page_profile::resolve_body_font(), 0);
lv_obj_set_style_pad_row(p.root, profile.top_content_gap, 0);
lv_obj_remove_flag(p.root, LV_OBJ_FLAG_SCROLLABLE);
::ui::widgets::top_bar_init(p.topbar, p.root);
::ui::widgets::top_bar_set_title(p.topbar, "Geocaching");
addFocusable(p.topbar.back_btn);
::ui::widgets::top_bar_set_back_callback(
p.topbar, [](void*)
{
if (page && page->editor) editorBack();
else if (page && page->details) closeDetails();
else if (page && page->host) ::ui::page::request_exit(page->host); },
nullptr);
auto* tabs = lv_obj_create(p.root);
lv_obj_remove_style_all(tabs);
lv_obj_set_size(tabs, LV_PCT(100), profile.filter_button_height);
lv_obj_set_style_pad_hor(tabs, 4, 0);
lv_obj_set_style_pad_column(tabs, 4, 0);
lv_obj_set_flex_flow(tabs, LV_FLEX_FLOW_ROW);
lv_obj_remove_flag(tabs, LV_OBJ_FLAG_SCROLLABLE);
const char* names[] = {"Discover", "Downloaded", "My caches"};
for (uintptr_t i = 0; i < 3; ++i)
{
p.tabs[i] = button(tabs, names[i], profile.filter_button_height);
lv_obj_set_width(p.tabs[i], 0);
lv_obj_set_flex_grow(p.tabs[i], 1);
lv_obj_add_event_cb(
p.tabs[i], [](lv_event_t* event)
{
if (!page) return;
page->section = static_cast<Section>(reinterpret_cast<uintptr_t>(lv_event_get_user_data(event)));
page->offset = 0; closeDetails(); },
LV_EVENT_CLICKED, reinterpret_cast<void*>(i));
}
p.status = lv_label_create(p.root);
lv_obj_set_width(p.status, LV_PCT(100));
lv_label_set_long_mode(p.status, LV_LABEL_LONG_DOT);
lv_obj_set_style_pad_hor(p.status, 6, 0);
styles::apply_label_muted(p.status);
p.list = lv_obj_create(p.root);
lv_obj_remove_style_all(p.list);
styles::apply_container_main(p.list);
lv_obj_set_width(p.list, LV_PCT(100));
lv_obj_set_height(p.list, 0);
lv_obj_set_flex_grow(p.list, 1);
lv_obj_set_flex_flow(p.list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(p.list, 3, 0);
lv_obj_remove_flag(p.list, LV_OBJ_FLAG_SCROLLABLE);
auto* footer = lv_obj_create(p.root);
lv_obj_remove_style_all(footer);
lv_obj_set_size(footer, LV_PCT(100), profile.control_button_height);
lv_obj_set_style_pad_hor(footer, 4, 0);
lv_obj_set_style_pad_column(footer, 4, 0);
lv_obj_set_flex_flow(footer, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(footer, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(footer, LV_OBJ_FLAG_SCROLLABLE);
p.refresh = button(footer, "Refresh", profile.control_button_height);
lv_obj_set_width(p.refresh, profile.control_button_min_width);
lv_obj_add_event_cb(
p.refresh, [](lv_event_t*)
{
if (page && page->editor) editorBack();
else if (page && page->details) closeDetails();
else if (page && page->section == Section::Published && page->snapshot.can_create) openEditor(nullptr);
else if (page && source) { source->refresh(page->section); refreshView(); } },
LV_EVENT_CLICKED, nullptr);
p.range = lv_label_create(footer);
lv_obj_set_flex_grow(p.range, 1);
lv_obj_set_style_text_align(p.range, LV_TEXT_ALIGN_CENTER, 0);
styles::apply_label_muted(p.range);
p.previous = button(footer, LV_SYMBOL_LEFT, profile.control_button_height);
p.next = button(footer, LV_SYMBOL_RIGHT, profile.control_button_height);
lv_obj_set_width(p.previous, profile.control_button_height);
lv_obj_set_width(p.next, profile.control_button_height);
lv_obj_add_event_cb(
p.previous, [](lv_event_t*)
{
if (page && page->editor) { if (page->editor->confirming && !page->editor->saving) closeEditor(); else previewPublication(); }
else if (page) { page->offset = page->offset >= page->window ? page->offset - page->window : 0; refreshView(); } },
LV_EVENT_CLICKED, nullptr);
lv_obj_add_event_cb(
p.next, [](lv_event_t*)
{
if (!page) return;
if (page->editor) { saveEditor(); return; }
if (page->details)
{
::ui::geocaching::Item item;
if (source && source->item(page->section, page->detail_index, page->detail_generation, item) &&
item.id == page->detail_id && item.revision_hash == page->detail_hash &&
(item.downloaded ? openMap(item) : item.can_download && source->download(item, page->detail_generation)))
{ if (!map_visit) refreshView(); }
else lv_label_set_text(page->status, "Download could not start");
return;
}
if (page->offset + page->row_count < page->snapshot.count) page->offset += page->window;
else if (page->snapshot.has_more && source && source->loadMore()) { page->offset = 0; page->valid = false; }
refreshView(); },
LV_EVENT_CLICKED, nullptr);
lv_label_set_text(p.status, " ");
lv_obj_update_layout(p.root);
const auto available = std::max<lv_coord_t>(0, lv_obj_get_content_height(p.list));
p.window = std::max<size_t>(1, std::min<size_t>(kVisibleRows, (available + 3) / (profile.list_item_height + 3)));
refreshView();
if (p.group)
{
set_default_group(p.group);
lv_group_set_editing(p.group, false);
lv_group_focus_obj(p.tabs[static_cast<size_t>(p.section)]);
}
else set_default_group(p.previous_group);
p.timer = lv_timer_create([](lv_timer_t*)
{ refreshView(); },
500, nullptr);
}
void exit(void*, lv_obj_t*)
{
if (!page) return;
if (page->editor && page->editor->loading && source) source->cancelDraftRead(page->editor->input.id);
delete page->editor;
page->editor = nullptr;
if (map_visit)
{
lv_async_call_cancel(returnFromMap, nullptr);
::gps::ui::runtime::exit(map_visit->parent);
delete map_visit;
map_visit = nullptr;
}
if (page->timer) lv_timer_delete(page->timer);
if (page->group && lv_group_get_default() == page->group) set_default_group(page->previous_group);
if (page->root) lv_obj_delete(page->root);
if (page->group) lv_group_delete(page->group);
if (source) source->activate(false);
delete page;
page = nullptr;
}
} // namespace geocaching::ui::shell
@@ -6,6 +6,7 @@ using Projection = gps::ui::shell::Projection;
#include "app/app_config.h"
#include "app/app_facade_access.h"
#include "gps/domain/gps_diagnostics.h"
#include "gps/gpx/attribute_reader.h"
#include "platform/ui/device_runtime.h"
#include "platform/ui/gps_runtime.h"
#include "platform/ui/route_storage.h"
@@ -35,6 +36,7 @@ using Projection = gps::ui::shell::Projection;
#include "ui_presentation/map/map_overlay_snapshot.h"
#include "ui_presentation/map/map_workspace_model.h"
#include "ui/menu/dashboard/dashboard_style.h"
#include <algorithm>
#include <cctype>
#include <cmath>
@@ -166,6 +168,7 @@ int s_map_pan_y = 0;
bool s_map_view_initialized = false;
bool s_map_info_visible = true;
::ui::map::MapOverlaySnapshot* s_overlay_snapshot = nullptr;
const ::gps::ui::runtime::MapTarget* s_map_target = nullptr;
Projection s_projection = Projection::Map;
bool s_gps_power_lease_active = false;
lv_obj_t* s_gps_status_label = nullptr;
@@ -381,7 +384,7 @@ bool has_valid_viewport_center(const ::ui::map::MapViewport& viewport)
{
return std::isfinite(viewport.center_lat) &&
std::isfinite(viewport.center_lon) &&
(viewport.center_lat != 0.0 || viewport.center_lon != 0.0);
(s_map_target || viewport.center_lat != 0.0 || viewport.center_lon != 0.0);
}
void sync_workspace_layers_from_renderer()
@@ -1683,6 +1686,29 @@ void sync_map_notice_overlay()
lv_obj_move_foreground(s_map_notice_panel);
return;
}
if (s_map_target)
{
char notice[64]{};
double lat = 0.0, lon = 0.0;
if (current_fix_lat_lon(lat, lon))
{
const double target_lat = s_map_target->latitude_e7 / 10000000.0;
const double target_lon = s_map_target->longitude_e7 / 10000000.0;
const double meters = ::ui::menu::dashboard::haversine_m(lat, lon, target_lat, target_lon);
const float bearing = ::ui::menu::dashboard::bearing_between(lat, lon, target_lat, target_lon);
char distance[20]{};
::ui::menu::dashboard::format_distance(meters, distance, sizeof(distance));
if (meters < 1.0) std::snprintf(notice, sizeof(notice), "Cache: <1 m (straight line)");
else std::snprintf(notice, sizeof(notice), "Cache: %s %s %.0f deg N", distance,
::ui::menu::dashboard::compass_rose(bearing), static_cast<double>(bearing));
}
else std::snprintf(notice, sizeof(notice), "Cache: waiting for GPS fix");
set_compact_label(s_map_notice_label, notice);
lv_obj_set_style_bg_color(s_map_notice_panel, lv_color_hex(0x25170D), 0);
lv_obj_clear_flag(s_map_notice_panel, LV_OBJ_FLAG_HIDDEN);
lv_obj_move_foreground(s_map_notice_panel);
return;
}
lv_obj_set_style_bg_color(s_map_notice_panel, lv_color_hex(0x25170D), 0);
lv_obj_add_flag(s_map_notice_panel, LV_OBJ_FLAG_HIDDEN);
}
@@ -1948,19 +1974,7 @@ bool parse_double_token(const std::string& token, double& out)
bool parse_attr_double(const std::string& line, const char* key, double& out)
{
const std::string token = std::string(key) + "=\"";
const std::size_t start = line.find(token);
if (start == std::string::npos)
{
return false;
}
const std::size_t value_start = start + token.size();
const std::size_t value_end = line.find('"', value_start);
if (value_end == std::string::npos || value_end <= value_start)
{
return false;
}
return parse_double_token(line.substr(value_start, value_end - value_start), out);
return key && ::gps::gpx::readDoubleAttribute(line, key, out);
}
bool ascii_equal_ignore_case(const std::string& value, const char* expected)
@@ -3103,10 +3117,43 @@ bool load_map_track_file_impl(const char* path, bool show_fail_toast)
return true;
}
bool select_cache_at(lv_point_t point)
{
if (!s_map_target || !s_map_target->select_overlay || !s_overlay_snapshot) return false;
const ::ui::map::MapOverlayItem* closest = nullptr;
int64_t best = 24 * 24 + 1;
for (size_t index = 0; index < s_overlay_snapshot->item_count; ++index)
{
const auto& item = s_overlay_snapshot->items[index];
if (item.kind != ::ui::map::MapOverlayKind::Geocache || !item.visible || !item.point.valid) continue;
lv_point_t projected;
if (!::ui::widgets::map::project_point(s_map_runtime, {true, item.point.lat, item.point.lon}, projected)) continue;
const int64_t dx = int64_t(point.x) - projected.x, dy = int64_t(point.y) - projected.y;
const auto distance = dx * dx + dy * dy;
if (distance < best)
{
best = distance;
closest = &item;
}
}
if (!closest) return false;
s_map_target->select_overlay(s_map_target->overlay_context, closest->stable_id);
return true;
}
void map_gesture_callback(const ::ui::widgets::map::GestureEvent& event, void*)
{
switch (event.phase)
{
case ::ui::widgets::map::GesturePhase::Tapped:
{
const auto* root = ::ui::widgets::map::widgets(s_map_runtime).root;
if (!root) break;
lv_area_t bounds;
lv_obj_get_coords(root, &bounds);
(void)select_cache_at({event.point.x - bounds.x1, event.point.y - bounds.y1});
break;
}
case ::ui::widgets::map::GesturePhase::Pressed:
s_map_drag_start_pan_x = s_map_pan_x;
s_map_drag_start_pan_y = s_map_pan_y;
@@ -3179,8 +3226,22 @@ void refresh_view()
sync_workspace_layers_from_renderer();
auto snapshot = map_workspace_model().snapshot();
(void)map_overlay_source().buildMapOverlaySnapshot(*s_overlay_snapshot);
if (s_map_target && s_overlay_snapshot->item_count < ::ui::map::MapOverlaySnapshot::kMaxItems)
{
auto& target = s_overlay_snapshot->items[s_overlay_snapshot->item_count++];
target = ::ui::map::MapOverlayItem{};
target.kind = ::ui::map::MapOverlayKind::Geocache;
target.style = ::ui::map::MapOverlayStyle::Warning;
target.point.valid = true;
target.point.lat = s_map_target->latitude_e7 / 10000000.0;
target.point.lon = s_map_target->longitude_e7 / 10000000.0;
target.selected = target.visible = true;
::ui::copyText(target.label, s_map_target->name);
}
append_route_image_overlay(*s_overlay_snapshot);
append_track_overlay(*s_overlay_snapshot);
if (s_map_target && s_map_target->append_overlays)
s_map_target->append_overlays(s_map_target->overlay_context, *s_overlay_snapshot);
if (!s_map_info_visible)
{
keep_only_current_position_overlay(*s_overlay_snapshot);
@@ -3915,6 +3976,14 @@ void on_map_control_clicked(lv_event_t* e)
bool handle_map_key(uint32_t key, lv_event_t* e)
{
if (s_map_target && (key == 'g' || key == 'G'))
{
auto* root = ::ui::widgets::map::widgets(s_map_runtime).root;
if (root && !select_cache_at({lv_obj_get_width(root) / 2, lv_obj_get_height(root) / 2}))
set_map_notice("No cache near map center", 1200);
consume_key_event(e);
return true;
}
if (::ui::widgets::route_image_strip::handle_key(s_route_image_strip, key))
{
consume_key_event(e);
@@ -4491,9 +4560,28 @@ void enter(const shell::Host* host, lv_obj_t* parent, shell::Projection projecti
}
}
bool enter_target(const shell::Host* host, lv_obj_t* parent, const MapTarget& target)
{
if (!parent || s_root || target.latitude_e7 < -900000000 || target.latitude_e7 > 900000000 ||
target.longitude_e7 < -1800000000 || target.longitude_e7 >= 1800000000 || !is_available()) return false;
enter(host, parent, shell::Projection::Map);
if (!s_root) return false;
s_map_target = &target;
auto viewport = map_workspace_model().viewport();
viewport.center_lat = target.latitude_e7 / 10000000.0;
viewport.center_lon = target.longitude_e7 / 10000000.0;
viewport.zoom = current_map_zoom();
(void)map_workspace_model().setViewport(viewport);
s_map_pan_x = s_map_pan_y = 0;
set_map_notice("Tap cache or G near map center", 3500);
refresh_view();
return true;
}
void exit(lv_obj_t* parent)
{
(void)parent;
s_map_target = nullptr;
if (s_timer)
{
@@ -362,6 +362,10 @@ void gesture_surface_event_cb(lv_event_t* e)
point);
}
else if (code == LV_EVENT_RELEASED)
{
emit_gesture_event(*impl, GesturePhase::Tapped, point);
}
reset_gesture_state(*impl);
break;