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"
|
||||
minApiLevel="3.2.0"
|
||||
name="@Strings.AppName"
|
||||
type="watch-app">
|
||||
type="widget">
|
||||
<iq:products>
|
||||
<iq:product id="fenix7"/>
|
||||
<iq:product id="fr265"/>
|
||||
|
|
|
|||
|
|
@ -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) -------------------
|
||||
|
|
|
|||
|
|
@ -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() ];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
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 (_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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue