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:
parent
eea1a835cd
commit
79cdb9f210
9 changed files with 134 additions and 12 deletions
|
|
@ -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"/>
|
||||||
|
|
|
||||||
|
|
@ -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) -------------------
|
||||||
|
|
|
||||||
|
|
@ -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() ];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
83
source/GlanceView.mc
Normal 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")
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue