diff --git a/manifest.xml b/manifest.xml index 7dd4ae5..e56bd99 100644 --- a/manifest.xml +++ b/manifest.xml @@ -4,7 +4,7 @@ launcherIcon="@Drawables.LauncherIcon" minApiLevel="3.2.0" name="@Strings.AppName" - type="watch-app"> + type="widget"> diff --git a/source/Config.mc b/source/Config.mc index e3a86db..0eea8a6 100644 --- a/source/Config.mc +++ b/source/Config.mc @@ -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) ------------------- diff --git a/source/EinsatzprotokollApp.mc b/source/EinsatzprotokollApp.mc index f2db9e9..60d0443 100644 --- a/source/EinsatzprotokollApp.mc +++ b/source/EinsatzprotokollApp.mc @@ -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() ]; + } } diff --git a/source/Event.mc b/source/Event.mc index 59c6bce..73e3968 100644 --- a/source/Event.mc +++ b/source/Event.mc @@ -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; } } diff --git a/source/EventStore.mc b/source/EventStore.mc index 826349c..960b8c1 100644 --- a/source/EventStore.mc +++ b/source/EventStore.mc @@ -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); diff --git a/source/GeocodingService.mc b/source/GeocodingService.mc index 9fe4c4d..9ca0bb1 100644 --- a/source/GeocodingService.mc +++ b/source/GeocodingService.mc @@ -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; } } diff --git a/source/GlanceView.mc b/source/GlanceView.mc new file mode 100644 index 0000000..bcec52c --- /dev/null +++ b/source/GlanceView.mc @@ -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") + ]); + } +} diff --git a/source/HistoryView.mc b/source/HistoryView.mc index 5a2ff57..881c8d3 100644 --- a/source/HistoryView.mc +++ b/source/HistoryView.mc @@ -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(); } diff --git a/source/Logger.mc b/source/Logger.mc index 605fcb7..7465d2f 100644 --- a/source/Logger.mc +++ b/source/Logger.mc @@ -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";