Phase 6: glance widget with cached address

App type changed to widget for glance support. GlanceView shows
event type, date/time, and street+housenumber (or coordinates as
fallback). Addresses are cached in Event storage on first view in
history. Glance-required modules annotated with (:glance). Address
distance threshold raised to 70m.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
EiSiMo 2026-04-13 15:31:46 +02:00
parent eea1a835cd
commit 79cdb9f210
9 changed files with 134 additions and 12 deletions

View file

@ -3,6 +3,7 @@ import Toybox.Lang;
// Central configuration. All tunable values live here so they can be
// changed without touching feature code.
(:glance)
module Config {
// --- Retention ------------------------------------------------------
@ -25,7 +26,7 @@ module Config {
const DELETE_HOLD_MS = 2500;
// --- Address resolution --------------------------------------------
const ADDRESS_MAX_DISTANCE_M = 20;
const ADDRESS_MAX_DISTANCE_M = 70;
const PHOTON_URL = "https://photon.komoot.io/reverse";
// --- Colors (AMOLED: black background, white fg) -------------------

View file

@ -19,4 +19,8 @@ class EinsatzprotokollApp extends Application.AppBase {
var view = new MenuView();
return [ view, new MenuDelegate(view) ];
}
function getGlanceView() as [WatchUi.GlanceView] or [WatchUi.GlanceView, WatchUi.GlanceViewDelegate] or Null {
return [ new GlanceView() ];
}
}

View file

@ -2,12 +2,14 @@ import Toybox.Lang;
// Value object for a protocol entry. Serialized as a plain Dictionary
// so Application.Storage can persist it.
(:glance)
class Event {
public var type as String;
public var timestamp as Number; // unix seconds
public var lat as Float or Null;
public var lon as Float or Null;
public var accuracy as Float or Null;
public var address as String or Null;
function initialize(type as String, timestamp as Number,
lat as Float or Null, lon as Float or Null,
@ -17,25 +19,34 @@ class Event {
self.lat = lat;
self.lon = lon;
self.accuracy = accuracy;
self.address = null;
}
function toDict() as Dictionary {
return {
var d = {
"t" => type,
"ts" => timestamp,
"lat" => lat,
"lon" => lon,
"acc" => accuracy
};
if (address != null) {
d["addr"] = address;
}
return d;
}
static function fromDict(d as Dictionary) as Event {
return new Event(
var evt = new Event(
d["t"] as String,
d["ts"] as Number,
d["lat"] as Float or Null,
d["lon"] as Float or Null,
d["acc"] as Float or Null
);
if (d.hasKey("addr")) {
evt.address = d["addr"] as String or Null;
}
return evt;
}
}

View file

@ -5,6 +5,7 @@ import Toybox.Time;
// Persists events in Application.Storage as an array of dictionaries,
// ordered oldest newest. Prunes entries older than Config.RETENTION_SEC
// whenever loaded.
(:glance)
module EventStore {
const KEY = "events";
@ -51,6 +52,15 @@ module EventStore {
return Event.fromDict(raw[raw.size() - 1] as Dictionary);
}
function updateAt(index as Number, event as Event) as Void {
var raw = Application.Storage.getValue(KEY);
if (!(raw instanceof Array) || index < 0 || index >= raw.size()) {
return;
}
raw[index] = event.toDict();
Application.Storage.setValue(KEY, raw);
}
// Drops events with timestamp < (now - retention).
function pruneOld() as Void {
var raw = Application.Storage.getValue(KEY);

View file

@ -28,13 +28,16 @@ class GeocodingService {
}
function _onResponse(responseCode as Number, data as Dictionary or String or Null) as Void {
System.println("GEO: responseCode=" + responseCode);
if (responseCode != 200 || data == null || !(data instanceof Dictionary)) {
System.println("GEO: bad response, data=" + data);
_callback.invoke(null);
return;
}
var features = data["features"];
if (features == null || !(features instanceof Array) || features.size() == 0) {
System.println("GEO: no features");
_callback.invoke(null);
return;
}
@ -42,6 +45,7 @@ class GeocodingService {
var feature = features[0] as Dictionary;
var props = feature["properties"] as Dictionary;
var geometry = feature["geometry"] as Dictionary;
System.println("GEO: props=" + props);
// Haversine check: only use address if result is within threshold.
if (geometry != null) {
@ -50,20 +54,23 @@ class GeocodingService {
var rLon = (coords[0] as Double).toFloat();
var rLat = (coords[1] as Double).toFloat();
var dist = Haversine.distance(_lat, _lon, rLat, rLon);
System.println("GEO: dist=" + dist + " max=" + Config.ADDRESS_MAX_DISTANCE_M);
if (dist > Config.ADDRESS_MAX_DISTANCE_M) {
System.println("GEO: too far, rejecting");
_callback.invoke(null);
return;
}
}
}
_callback.invoke(_formatAddress(props));
var addr = _formatAddress(props);
System.println("GEO: address=" + addr);
_callback.invoke(addr);
}
private function _formatAddress(props as Dictionary) as String {
var street = props["street"];
var number = props["housenumber"];
var city = props["city"];
var parts = "";
if (street != null) {
@ -72,12 +79,6 @@ class GeocodingService {
parts = parts + " " + number;
}
}
if (city != null) {
if (!parts.equals("")) {
parts = parts + ", ";
}
parts = parts + city;
}
return parts.equals("") ? "Unbekannt" : parts;
}
}

83
source/GlanceView.mc Normal file
View file

@ -0,0 +1,83 @@
import Toybox.Graphics;
import Toybox.Lang;
import Toybox.Time;
import Toybox.Time.Gregorian;
import Toybox.WatchUi;
// Glance shown in the widget list. Shows the most recent event:
// line 1: event type
// line 2: date + time
// line 3: street + housenumber (if cached)
(:glance)
class GlanceView extends WatchUi.GlanceView {
function initialize() {
GlanceView.initialize();
}
function onUpdate(dc as Dc) as Void {
dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
dc.clear();
var h = dc.getHeight();
var font = Graphics.FONT_GLANCE;
var lineH = dc.getFontHeight(font);
var margin = 4;
var latest = EventStore.latest();
if (latest == null) {
dc.drawText(margin, h / 2, font, "Keine Einträge",
Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);
return;
}
var totalH = 3 * lineH;
var startY = (h - totalH) / 2 + lineH / 2;
// Line 1: event type.
dc.drawText(margin, startY, font,
_eventLabel(latest.type),
Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);
// Line 2: date + time.
dc.drawText(margin, startY + lineH, font,
_formatTimestamp(latest.timestamp),
Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);
// Line 3: address or coordinates.
var line3;
if (latest.address != null) {
line3 = latest.address as String;
} else if (latest.lat != null && latest.lon != null) {
line3 = (latest.lat as Float).format("%.4f") + ", " + (latest.lon as Float).format("%.4f");
} else {
line3 = "Kein Standort";
}
dc.drawText(margin, startY + lineH * 2, font, line3,
Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);
}
private function _eventLabel(key as String) as String {
if (key.equals(Config.EVENT_GENERAL)) { return "Ereignis"; }
if (key.equals(Config.EVENT_START)) { return "Einsatzbeginn"; }
if (key.equals(Config.EVENT_END)) { return "Einsatzende"; }
if (key.equals(Config.EVENT_ARRIVAL)) { return "Eintreffen"; }
if (key.equals(Config.EVENT_ARREST)) { return "Festnahme"; }
if (key.equals(Config.EVENT_FORCE)) { return "Zwang"; }
if (key.equals(Config.EVENT_EVIDENCE)) { return "Beweismittel"; }
if (key.equals(Config.EVENT_SIGHTING)) { return "Sichtung"; }
return key;
}
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")
]);
}
}

View file

@ -62,6 +62,11 @@ class HistoryView extends WatchUi.View {
if (_events.size() == 0) { return; }
if (_addressCache.hasKey(_index)) { return; }
var evt = _events[_index];
// Use cached address from event if available.
if (evt.address != null) {
_addressCache[_index] = evt.address;
return;
}
if (evt.lat == null || evt.lon == null) { return; }
_addressLoading = true;
_geocoder = new GeocodingService(
@ -74,6 +79,12 @@ class HistoryView extends WatchUi.View {
function _onAddress(address as String or Null) as Void {
_addressCache[_index] = address;
_addressLoading = false;
// Persist address in the event so the glance can show it.
if (address != null && _index < _events.size()) {
var evt = _events[_index];
evt.address = address;
EventStore.updateAt(_index, evt);
}
WatchUi.requestUpdate();
}

View file

@ -5,6 +5,7 @@ import Toybox.Time;
// Lightweight crash / diagnostic logger. Entries live in
// Application.Storage and are pruned with the same retention window
// as events so the watch storage never fills up.
(:glance)
module Logger {
const KEY = "logs";