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

@ -4,7 +4,7 @@
launcherIcon="@Drawables.LauncherIcon" launcherIcon="@Drawables.LauncherIcon"
minApiLevel="3.2.0" minApiLevel="3.2.0"
name="@Strings.AppName" name="@Strings.AppName"
type="watch-app"> type="widget">
<iq:products> <iq:products>
<iq:product id="fenix7"/> <iq:product id="fenix7"/>
<iq:product id="fr265"/> <iq:product id="fr265"/>

View file

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

View file

@ -19,4 +19,8 @@ class EinsatzprotokollApp extends Application.AppBase {
var view = new MenuView(); var view = new MenuView();
return [ view, new MenuDelegate(view) ]; 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 // Value object for a protocol entry. Serialized as a plain Dictionary
// so Application.Storage can persist it. // so Application.Storage can persist it.
(:glance)
class Event { class Event {
public var type as String; public var type as String;
public var timestamp as Number; // unix seconds public var timestamp as Number; // unix seconds
public var lat as Float or Null; public var lat as Float or Null;
public var lon as Float or Null; public var lon as Float or Null;
public var accuracy 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, function initialize(type as String, timestamp as Number,
lat as Float or Null, lon as Float or Null, lat as Float or Null, lon as Float or Null,
@ -17,25 +19,34 @@ class Event {
self.lat = lat; self.lat = lat;
self.lon = lon; self.lon = lon;
self.accuracy = accuracy; self.accuracy = accuracy;
self.address = null;
} }
function toDict() as Dictionary { function toDict() as Dictionary {
return { var d = {
"t" => type, "t" => type,
"ts" => timestamp, "ts" => timestamp,
"lat" => lat, "lat" => lat,
"lon" => lon, "lon" => lon,
"acc" => accuracy "acc" => accuracy
}; };
if (address != null) {
d["addr"] = address;
}
return d;
} }
static function fromDict(d as Dictionary) as Event { static function fromDict(d as Dictionary) as Event {
return new Event( var evt = new Event(
d["t"] as String, d["t"] as String,
d["ts"] as Number, d["ts"] as Number,
d["lat"] as Float or Null, d["lat"] as Float or Null,
d["lon"] as Float or Null, d["lon"] as Float or Null,
d["acc"] 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, // Persists events in Application.Storage as an array of dictionaries,
// ordered oldest newest. Prunes entries older than Config.RETENTION_SEC // ordered oldest newest. Prunes entries older than Config.RETENTION_SEC
// whenever loaded. // whenever loaded.
(:glance)
module EventStore { module EventStore {
const KEY = "events"; const KEY = "events";
@ -51,6 +52,15 @@ module EventStore {
return Event.fromDict(raw[raw.size() - 1] as Dictionary); 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). // Drops events with timestamp < (now - retention).
function pruneOld() as Void { function pruneOld() as Void {
var raw = Application.Storage.getValue(KEY); 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 { 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)) { if (responseCode != 200 || data == null || !(data instanceof Dictionary)) {
System.println("GEO: bad response, data=" + data);
_callback.invoke(null); _callback.invoke(null);
return; return;
} }
var features = data["features"]; var features = data["features"];
if (features == null || !(features instanceof Array) || features.size() == 0) { if (features == null || !(features instanceof Array) || features.size() == 0) {
System.println("GEO: no features");
_callback.invoke(null); _callback.invoke(null);
return; return;
} }
@ -42,6 +45,7 @@ class GeocodingService {
var feature = features[0] as Dictionary; var feature = features[0] as Dictionary;
var props = feature["properties"] as Dictionary; var props = feature["properties"] as Dictionary;
var geometry = feature["geometry"] as Dictionary; var geometry = feature["geometry"] as Dictionary;
System.println("GEO: props=" + props);
// Haversine check: only use address if result is within threshold. // Haversine check: only use address if result is within threshold.
if (geometry != null) { if (geometry != null) {
@ -50,20 +54,23 @@ class GeocodingService {
var rLon = (coords[0] as Double).toFloat(); var rLon = (coords[0] as Double).toFloat();
var rLat = (coords[1] as Double).toFloat(); var rLat = (coords[1] as Double).toFloat();
var dist = Haversine.distance(_lat, _lon, rLat, rLon); 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) { if (dist > Config.ADDRESS_MAX_DISTANCE_M) {
System.println("GEO: too far, rejecting");
_callback.invoke(null); _callback.invoke(null);
return; 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 { private function _formatAddress(props as Dictionary) as String {
var street = props["street"]; var street = props["street"];
var number = props["housenumber"]; var number = props["housenumber"];
var city = props["city"];
var parts = ""; var parts = "";
if (street != null) { if (street != null) {
@ -72,12 +79,6 @@ class GeocodingService {
parts = parts + " " + number; parts = parts + " " + number;
} }
} }
if (city != null) {
if (!parts.equals("")) {
parts = parts + ", ";
}
parts = parts + city;
}
return parts.equals("") ? "Unbekannt" : parts; 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 (_events.size() == 0) { return; }
if (_addressCache.hasKey(_index)) { return; } if (_addressCache.hasKey(_index)) { return; }
var evt = _events[_index]; 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; } if (evt.lat == null || evt.lon == null) { return; }
_addressLoading = true; _addressLoading = true;
_geocoder = new GeocodingService( _geocoder = new GeocodingService(
@ -74,6 +79,12 @@ class HistoryView extends WatchUi.View {
function _onAddress(address as String or Null) as Void { function _onAddress(address as String or Null) as Void {
_addressCache[_index] = address; _addressCache[_index] = address;
_addressLoading = false; _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(); WatchUi.requestUpdate();
} }

View file

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