Phase 4: history view with reverse geocoding

3-section layout (15/70/15) for browsing recorded events.
Reverse-geocodes coordinates via Photon API with Haversine
distance check and address caching. Also adds simulator GPS
polling fallback (Position.getInfo) since the simulator does
not fire location event callbacks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
EiSiMo 2026-04-13 14:31:44 +02:00
parent 025d3007db
commit 902121bd42
7 changed files with 368 additions and 11 deletions

226
source/HistoryView.mc Normal file
View file

@ -0,0 +1,226 @@
import Toybox.Graphics;
import Toybox.Lang;
import Toybox.Time;
import Toybox.Time.Gregorian;
import Toybox.WatchUi;
// Scrollable history of recorded events. 3-section layout:
// top 15%: previous entry icon + up arrow
// mid 70%: current entry details (type, datetime, address/coords)
// bot 15%: next entry icon + down arrow
class HistoryView extends WatchUi.View {
private var _events as Array<Event>;
private var _index as Number = 0;
private var _bitmaps as Dictionary = {};
private var _addressCache as Dictionary = {}; // index String or Null
private var _addressLoading as Boolean = false;
private var _geocoder as GeocodingService or Null = null;
function initialize() {
View.initialize();
_events = EventStore.getAll();
// Start at newest entry.
if (_events.size() > 0) {
_index = _events.size() - 1;
}
}
function onLayout(dc as Dc) as Void {
var items = Config.menuItems();
for (var i = 0; i < items.size(); i++) {
var iconId = items[i][:icon];
_bitmaps[items[i][:key]] = WatchUi.loadResource(iconId) as BitmapResource;
}
}
function eventCount() as Number {
return _events.size();
}
function showNext() as Void {
if (_events.size() == 0) { return; }
_index = (_index + 1) % _events.size();
_addressLoading = false;
_requestAddress();
WatchUi.requestUpdate();
}
function showPrev() as Void {
if (_events.size() == 0) { return; }
_index = (_index - 1 + _events.size()) % _events.size();
_addressLoading = false;
_requestAddress();
WatchUi.requestUpdate();
}
function onShow() as Void {
_requestAddress();
}
private function _requestAddress() as Void {
if (_events.size() == 0) { return; }
if (_addressCache.hasKey(_index)) { return; }
var evt = _events[_index];
if (evt.lat == null || evt.lon == null) { return; }
_addressLoading = true;
_geocoder = new GeocodingService(
evt.lat as Float, evt.lon as Float,
method(:_onAddress)
);
_geocoder.start();
}
function _onAddress(address as String or Null) as Void {
_addressCache[_index] = address;
_addressLoading = false;
WatchUi.requestUpdate();
}
function onUpdate(dc as Dc) as Void {
dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
dc.clear();
if (_events.size() == 0) {
var cx = LayoutMetrics.centerX(dc);
var cy = LayoutMetrics.centerY(dc);
TextUtils.drawResourceCentered(dc, Rez.Strings.history_empty, cx, cy, Graphics.FONT_MEDIUM);
return;
}
var cx = LayoutMetrics.centerX(dc);
var topH = LayoutMetrics.topSectionHeight(dc);
var botY = LayoutMetrics.bottomSectionY(dc);
var screenH = dc.getHeight();
_drawTopSection(dc, cx, topH);
_drawMiddleSection(dc, cx, topH, botY);
_drawBottomSection(dc, cx, botY, screenH);
}
private function _drawTopSection(dc as Dc, cx as Number, topH as Number) as Void {
if (_events.size() <= 1) { return; }
var midY = topH / 2;
var arrowSize = (topH * 0.2).toNumber();
dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
dc.setPenWidth(2);
dc.drawLine(cx, midY - arrowSize, cx, midY + arrowSize);
dc.drawLine(cx - arrowSize, midY, cx, midY - arrowSize);
dc.drawLine(cx + arrowSize, midY, cx, midY - arrowSize);
}
private function _drawBottomSection(dc as Dc, cx as Number, botY as Number, screenH as Number) as Void {
if (_events.size() <= 1) { return; }
var sectionH = screenH - botY;
var midY = botY + sectionH / 2;
var arrowSize = (sectionH * 0.2).toNumber();
dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
dc.setPenWidth(2);
dc.drawLine(cx, midY - arrowSize, cx, midY + arrowSize);
dc.drawLine(cx - arrowSize, midY, cx, midY + arrowSize);
dc.drawLine(cx + arrowSize, midY, cx, midY + arrowSize);
}
private function _drawMiddleSection(dc as Dc, cx as Number, topH as Number, botY as Number) as Void {
var evt = _events[_index];
var midY = topH + (botY - topH) / 2;
// Event type label.
var label = _eventLabel(evt.type);
var color = _eventColor(evt.type);
dc.setColor(color, Config.COLOR_BG);
var fontLabel = Graphics.FONT_SMALL;
var lineH = dc.getFontHeight(fontLabel);
dc.drawText(cx, midY - lineH * 2, fontLabel, label,
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
// Date + time.
dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
var fontDetail = Graphics.FONT_TINY;
var dateStr = _formatTimestamp(evt.timestamp);
dc.drawText(cx, midY - lineH / 2, fontDetail, dateStr,
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
// Address / coordinates / status.
var locStr = _locationString(evt);
dc.drawText(cx, midY + lineH / 2, fontDetail, locStr,
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
// Counter (e.g. "3/7").
dc.setColor(0x888888, Config.COLOR_BG);
dc.drawText(cx, midY + lineH * 3 / 2, Graphics.FONT_XTINY,
(_index + 1) + "/" + _events.size(),
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
}
private function _locationString(evt as Event) as String {
if (evt.lat == null || evt.lon == null) {
return WatchUi.loadResource(Rez.Strings.history_no_gps) as String;
}
if (_addressCache.hasKey(_index)) {
var cached = _addressCache[_index];
if (cached != null) {
return cached as String;
}
return _formatCoords(evt.lat as Float, evt.lon as Float);
}
if (_addressLoading) {
return WatchUi.loadResource(Rez.Strings.history_loading_address) as String;
}
return _formatCoords(evt.lat as Float, evt.lon as Float);
}
private function _formatCoords(lat as Float, lon as Float) as String {
return lat.format("%.4f") + ", " + lon.format("%.4f");
}
private function _formatTimestamp(ts as Number) as String {
var moment = new Time.Moment(ts);
var info = Gregorian.info(moment, Time.FORMAT_SHORT);
return Lang.format("$1$.$2$.$3$ $4$:$5$", [
info.day.format("%02d"),
info.month.format("%02d"),
info.year,
info.hour.format("%02d"),
info.min.format("%02d")
]);
}
private function _eventLabel(key as String) as String {
var items = Config.menuItems();
for (var i = 0; i < items.size(); i++) {
if ((items[i][:key] as String).equals(key)) {
var lines = items[i][:lines] as Array<String>;
var result = "";
for (var j = 0; j < lines.size(); j++) {
if (j > 0) { result += ""; }
result += lines[j];
}
return result;
}
}
return key;
}
private function _eventColor(key as String) as Number {
var items = Config.menuItems();
for (var i = 0; i < items.size(); i++) {
if ((items[i][:key] as String).equals(key)) {
return items[i][:color] as Number;
}
}
return Config.COLOR_FG;
}
private function _drawEventIcon(dc as Dc, key as String, cx as Number, cy as Number, size as Number) as Void {
var bmp = _bitmaps[key] as BitmapResource or Null;
if (bmp == null) { return; }
var bmpW = bmp.getWidth();
var scale = size.toFloat() / bmpW.toFloat();
dc.drawBitmap2(
cx - size / 2, cy - size / 2,
bmp,
{ :scaleX => scale, :scaleY => scale }
);
}
}