Custom icons, improved GPS, history layout, address fixes
- Replace placeholder SVG icons with custom PNG icons (80x80 padded) - AffineTransform for pixel-perfect icon centering in menu ring - Colored circles behind icons with icon drawn at 60% size - GPS: always wait full timeout (30s), keep best quality fix - History: show address, PLZ+city, coordinates on separate lines - Geocoding: fall back to "name" field for street-level results, include PLZ+city in cached address data - Address distance threshold raised to 70m Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
|
@ -1,13 +1,13 @@
|
||||||
<drawables>
|
<drawables>
|
||||||
<bitmap id="LauncherIcon" filename="launcher_icon.svg"/>
|
<bitmap id="LauncherIcon" filename="launcher_icon.png"/>
|
||||||
<bitmap id="IconHistory" filename="icon_history.svg" automaticPalette="false"/>
|
<bitmap id="IconHistory" filename="icon_history.png" automaticPalette="false"/>
|
||||||
<bitmap id="IconEvent" filename="icon_event.svg" automaticPalette="false"/>
|
<bitmap id="IconEvent" filename="icon_event.png" automaticPalette="false"/>
|
||||||
<bitmap id="IconStart" filename="icon_start.svg" automaticPalette="false"/>
|
<bitmap id="IconStart" filename="icon_start.png" automaticPalette="false"/>
|
||||||
<bitmap id="IconEnd" filename="icon_end.svg" automaticPalette="false"/>
|
<bitmap id="IconEnd" filename="icon_end.png" automaticPalette="false"/>
|
||||||
<bitmap id="IconArrival" filename="icon_arrival.svg" automaticPalette="false"/>
|
<bitmap id="IconArrival" filename="icon_arrival.png" automaticPalette="false"/>
|
||||||
<bitmap id="IconArrest" filename="icon_arrest.svg" automaticPalette="false"/>
|
<bitmap id="IconArrest" filename="icon_arrest.png" automaticPalette="false"/>
|
||||||
<bitmap id="IconForce" filename="icon_force.svg" automaticPalette="false"/>
|
<bitmap id="IconForce" filename="icon_force.png" automaticPalette="false"/>
|
||||||
<bitmap id="IconEvidence" filename="icon_evidence.svg" automaticPalette="false"/>
|
<bitmap id="IconEvidence" filename="icon_evidence.png" automaticPalette="false"/>
|
||||||
<bitmap id="IconSighting" filename="icon_sighting.svg" automaticPalette="false"/>
|
<bitmap id="IconSighting" filename="icon_sighting.png" automaticPalette="false"/>
|
||||||
<bitmap id="IconDelete" filename="icon_delete.svg" automaticPalette="false"/>
|
<bitmap id="IconDelete" filename="icon_delete.png" automaticPalette="false"/>
|
||||||
</drawables>
|
</drawables>
|
||||||
|
|
|
||||||
BIN
resources/drawables/icon_arrest.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
resources/drawables/icon_arrival.png
Normal file
|
After Width: | Height: | Size: 1,016 B |
BIN
resources/drawables/icon_delete.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
resources/drawables/icon_end.png
Normal file
|
After Width: | Height: | Size: 670 B |
BIN
resources/drawables/icon_event.png
Normal file
|
After Width: | Height: | Size: 676 B |
BIN
resources/drawables/icon_evidence.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
resources/drawables/icon_force.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
resources/drawables/icon_history.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
resources/drawables/icon_sighting.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
resources/drawables/icon_start.png
Normal file
|
After Width: | Height: | Size: 765 B |
BIN
resources/drawables/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
|
|
@ -10,8 +10,7 @@ module Config {
|
||||||
const RETENTION_SEC = 7 * 24 * 60 * 60;
|
const RETENTION_SEC = 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
// --- GPS ------------------------------------------------------------
|
// --- GPS ------------------------------------------------------------
|
||||||
const GPS_TIMEOUT_MS = 10000;
|
const GPS_TIMEOUT_MS = 30000;
|
||||||
const GPS_TARGET_ACCURACY_M = 5;
|
|
||||||
|
|
||||||
// --- Menu ring -----------------------------------------------------
|
// --- Menu ring -----------------------------------------------------
|
||||||
// Angle (degrees) at which the selected icon sits relative to the
|
// Angle (degrees) at which the selected icon sits relative to the
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ class Event {
|
||||||
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;
|
public var address as String or Null;
|
||||||
|
public var zip 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,
|
||||||
|
|
@ -20,6 +21,7 @@ class Event {
|
||||||
self.lon = lon;
|
self.lon = lon;
|
||||||
self.accuracy = accuracy;
|
self.accuracy = accuracy;
|
||||||
self.address = null;
|
self.address = null;
|
||||||
|
self.zip = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDict() as Dictionary {
|
function toDict() as Dictionary {
|
||||||
|
|
@ -30,9 +32,8 @@ class Event {
|
||||||
"lon" => lon,
|
"lon" => lon,
|
||||||
"acc" => accuracy
|
"acc" => accuracy
|
||||||
};
|
};
|
||||||
if (address != null) {
|
if (address != null) { d["addr"] = address; }
|
||||||
d["addr"] = address;
|
if (zip != null) { d["zip"] = zip; }
|
||||||
}
|
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,9 +45,8 @@ class Event {
|
||||||
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")) {
|
if (d.hasKey("addr")) { evt.address = d["addr"] as String or Null; }
|
||||||
evt.address = d["addr"] as String or Null;
|
if (d.hasKey("zip")) { evt.zip = d["zip"] as String or Null; }
|
||||||
}
|
|
||||||
return evt;
|
return evt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,15 @@ import Toybox.Communications;
|
||||||
import Toybox.Lang;
|
import Toybox.Lang;
|
||||||
|
|
||||||
// Reverse-geocodes a lat/lon pair via Photon API. Calls back with
|
// Reverse-geocodes a lat/lon pair via Photon API. Calls back with
|
||||||
// a formatted address string or null on failure.
|
// a Dictionary { "addr" => String, "zip" => String } or null.
|
||||||
class GeocodingService {
|
class GeocodingService {
|
||||||
|
|
||||||
private var _callback as Method(address as String or Null) as Void;
|
private var _callback as Method(result as Dictionary or Null) as Void;
|
||||||
private var _lat as Float;
|
private var _lat as Float;
|
||||||
private var _lon as Float;
|
private var _lon as Float;
|
||||||
|
|
||||||
function initialize(lat as Float, lon as Float,
|
function initialize(lat as Float, lon as Float,
|
||||||
callback as Method(address as String or Null) as Void) {
|
callback as Method(result as Dictionary or Null) as Void) {
|
||||||
_callback = callback;
|
_callback = callback;
|
||||||
_lat = lat;
|
_lat = lat;
|
||||||
_lon = lon;
|
_lon = lon;
|
||||||
|
|
@ -28,16 +28,13 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +42,6 @@ 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) {
|
||||||
|
|
@ -54,31 +50,38 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 street = props["street"];
|
||||||
var number = props["housenumber"];
|
if (street == null) { street = props["name"]; }
|
||||||
|
if (street == null) {
|
||||||
var parts = "";
|
_callback.invoke(null);
|
||||||
if (street != null) {
|
return;
|
||||||
parts = street as String;
|
|
||||||
if (number != null) {
|
|
||||||
parts = parts + " " + number;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return parts.equals("") ? "Unbekannt" : parts;
|
|
||||||
|
var addr = street as String;
|
||||||
|
var number = props["housenumber"];
|
||||||
|
if (number != null) {
|
||||||
|
addr = addr + " " + number;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = { "addr" => addr } as Dictionary;
|
||||||
|
var zip = props["postcode"];
|
||||||
|
var city = props["city"];
|
||||||
|
if (zip != null || city != null) {
|
||||||
|
var zipCity = "";
|
||||||
|
if (zip != null) { zipCity = zip as String; }
|
||||||
|
if (city != null) {
|
||||||
|
if (!zipCity.equals("")) { zipCity = zipCity + " "; }
|
||||||
|
zipCity = zipCity + city;
|
||||||
|
}
|
||||||
|
result["zip"] = zipCity;
|
||||||
|
}
|
||||||
|
_callback.invoke(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,17 +47,16 @@ class GpsService {
|
||||||
if (info == null || info.position == null) { return; }
|
if (info == null || info.position == null) { return; }
|
||||||
|
|
||||||
var degrees = info.position.toDegrees();
|
var degrees = info.position.toDegrees();
|
||||||
var acc = (info.accuracy != null) ? info.accuracy : null;
|
var quality = (info.accuracy != null) ? info.accuracy : 0;
|
||||||
|
|
||||||
_bestFix = {
|
// Always keep the best quality fix.
|
||||||
"lat" => degrees[0].toFloat(),
|
if (_bestFix == null || quality >= (_bestFix["q"] as Number)) {
|
||||||
"lon" => degrees[1].toFloat(),
|
_bestFix = {
|
||||||
"acc" => (acc != null) ? acc.toFloat() : null
|
"lat" => degrees[0].toFloat(),
|
||||||
};
|
"lon" => degrees[1].toFloat(),
|
||||||
|
"acc" => quality.toFloat(),
|
||||||
// Good enough → stop early.
|
"q" => quality
|
||||||
if (acc != null && acc <= Config.GPS_TARGET_ACCURACY_M) {
|
};
|
||||||
_finish(_bestFix);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ class HistoryView extends WatchUi.View {
|
||||||
var evt = _events[_index];
|
var evt = _events[_index];
|
||||||
// Use cached address from event if available.
|
// Use cached address from event if available.
|
||||||
if (evt.address != null) {
|
if (evt.address != null) {
|
||||||
_addressCache[_index] = evt.address;
|
_addressCache[_index] = { "addr" => evt.address, "zip" => evt.zip };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (evt.lat == null || evt.lon == null) { return; }
|
if (evt.lat == null || evt.lon == null) { return; }
|
||||||
|
|
@ -76,13 +76,14 @@ class HistoryView extends WatchUi.View {
|
||||||
_geocoder.start();
|
_geocoder.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _onAddress(address as String or Null) as Void {
|
function _onAddress(result as Dictionary or Null) as Void {
|
||||||
_addressCache[_index] = address;
|
_addressCache[_index] = result;
|
||||||
_addressLoading = false;
|
_addressLoading = false;
|
||||||
// Persist address in the event so the glance can show it.
|
// Persist in the event so the glance can show it.
|
||||||
if (address != null && _index < _events.size()) {
|
if (result != null && _index < _events.size()) {
|
||||||
var evt = _events[_index];
|
var evt = _events[_index];
|
||||||
evt.address = address;
|
evt.address = result["addr"] as String or Null;
|
||||||
|
evt.zip = result["zip"] as String or Null;
|
||||||
EventStore.updateAt(_index, evt);
|
EventStore.updateAt(_index, evt);
|
||||||
}
|
}
|
||||||
WatchUi.requestUpdate();
|
WatchUi.requestUpdate();
|
||||||
|
|
@ -134,51 +135,51 @@ class HistoryView extends WatchUi.View {
|
||||||
|
|
||||||
private function _drawMiddleSection(dc as Dc, cx as Number, topH as Number, botY as Number) as Void {
|
private function _drawMiddleSection(dc as Dc, cx as Number, topH as Number, botY as Number) as Void {
|
||||||
var evt = _events[_index];
|
var evt = _events[_index];
|
||||||
var midY = topH + (botY - topH) / 2;
|
var fontDetail = Graphics.FONT_XTINY;
|
||||||
|
var lineH = dc.getFontHeight(fontDetail);
|
||||||
|
var sectionMid = topH + (botY - topH) / 2;
|
||||||
|
|
||||||
// Event type label.
|
// Collect lines to draw.
|
||||||
var label = _eventLabel(evt.type);
|
var lines = [] as Array<Array>; // [text, color]
|
||||||
var color = _eventColor(evt.type);
|
|
||||||
dc.setColor(color, Config.COLOR_BG);
|
|
||||||
var fontLabel = Graphics.FONT_SMALL;
|
|
||||||
var lineH = dc.getFontHeight(fontLabel);
|
|
||||||
dc.drawText(cx, midY - lineH * 2, fontLabel, label,
|
|
||||||
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
|
|
||||||
|
|
||||||
// Date + time.
|
// 1: Event type (colored).
|
||||||
dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
|
lines.add([_eventLabel(evt.type), _eventColor(evt.type)]);
|
||||||
var fontDetail = Graphics.FONT_TINY;
|
|
||||||
var dateStr = _formatTimestamp(evt.timestamp);
|
|
||||||
dc.drawText(cx, midY - lineH / 2, fontDetail, dateStr,
|
|
||||||
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
|
|
||||||
|
|
||||||
// Address / coordinates / status.
|
// 2: Date + time.
|
||||||
var locStr = _locationString(evt);
|
lines.add([_formatTimestamp(evt.timestamp), Config.COLOR_FG]);
|
||||||
dc.drawText(cx, midY + lineH / 2, fontDetail, locStr,
|
|
||||||
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
|
|
||||||
|
|
||||||
// Counter (e.g. "3/7").
|
// 3: Address or status.
|
||||||
dc.setColor(0x888888, Config.COLOR_BG);
|
|
||||||
dc.drawText(cx, midY + lineH * 3 / 2, Graphics.FONT_XTINY,
|
|
||||||
(_index + 1) + "/" + _events.size(),
|
|
||||||
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function _locationString(evt as Event) as String {
|
|
||||||
if (evt.lat == null || evt.lon == null) {
|
if (evt.lat == null || evt.lon == null) {
|
||||||
return WatchUi.loadResource(Rez.Strings.history_no_gps) as String;
|
lines.add([WatchUi.loadResource(Rez.Strings.history_no_gps) as String, Config.COLOR_FG]);
|
||||||
}
|
} else if (_addressCache.hasKey(_index)) {
|
||||||
if (_addressCache.hasKey(_index)) {
|
var cached = _addressCache[_index] as Dictionary or Null;
|
||||||
var cached = _addressCache[_index];
|
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
return cached as String;
|
lines.add([cached["addr"] as String, Config.COLOR_FG]);
|
||||||
|
var zip = cached["zip"] as String or Null;
|
||||||
|
if (zip != null) {
|
||||||
|
lines.add([zip, 0x888888]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return _formatCoords(evt.lat as Float, evt.lon as Float);
|
} else if (_addressLoading) {
|
||||||
|
lines.add([WatchUi.loadResource(Rez.Strings.history_loading_address) as String, 0x888888]);
|
||||||
}
|
}
|
||||||
if (_addressLoading) {
|
|
||||||
return WatchUi.loadResource(Rez.Strings.history_loading_address) as String;
|
// 4: Coordinates (always, if available).
|
||||||
|
if (evt.lat != null && evt.lon != null) {
|
||||||
|
lines.add([_formatCoords(evt.lat as Float, evt.lon as Float), 0x888888]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5: Counter.
|
||||||
|
lines.add([(_index + 1) + "/" + _events.size(), 0x666666]);
|
||||||
|
|
||||||
|
// Draw centered vertically.
|
||||||
|
var totalH = lines.size() * lineH;
|
||||||
|
var startY = sectionMid - totalH / 2 + lineH / 2;
|
||||||
|
for (var i = 0; i < lines.size(); i++) {
|
||||||
|
dc.setColor(lines[i][1] as Number, Config.COLOR_BG);
|
||||||
|
dc.drawText(cx, startY + i * lineH, fontDetail, lines[i][0] as String,
|
||||||
|
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
|
||||||
}
|
}
|
||||||
return _formatCoords(evt.lat as Float, evt.lon as Float);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function _formatCoords(lat as Float, lon as Float) as String {
|
private function _formatCoords(lat as Float, lon as Float) as String {
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,11 @@ class MenuView extends WatchUi.View {
|
||||||
var isSelected = (i == _selectedIndex);
|
var isSelected = (i == _selectedIndex);
|
||||||
var targetSize = isSelected ? selSize : baseSize;
|
var targetSize = isSelected ? selSize : baseSize;
|
||||||
|
|
||||||
|
// Colored circle behind icon.
|
||||||
|
var color = _items[i][:color] as Number;
|
||||||
|
dc.setColor(color, Config.COLOR_BG);
|
||||||
|
dc.fillCircle(x, y, targetSize / 2);
|
||||||
|
|
||||||
var iconId = _items[i][:icon];
|
var iconId = _items[i][:icon];
|
||||||
var bmp = _bitmaps[iconId] as BitmapResource;
|
var bmp = _bitmaps[iconId] as BitmapResource;
|
||||||
if (bmp != null) {
|
if (bmp != null) {
|
||||||
|
|
@ -91,12 +96,12 @@ class MenuView extends WatchUi.View {
|
||||||
cx as Number, cy as Number,
|
cx as Number, cy as Number,
|
||||||
targetSize as Number) as Void {
|
targetSize as Number) as Void {
|
||||||
var bmpW = bmp.getWidth();
|
var bmpW = bmp.getWidth();
|
||||||
|
var bmpH = bmp.getHeight();
|
||||||
var scale = targetSize.toFloat() / bmpW.toFloat();
|
var scale = targetSize.toFloat() / bmpW.toFloat();
|
||||||
dc.drawBitmap2(
|
var tf = new Graphics.AffineTransform();
|
||||||
cx - targetSize / 2,
|
tf.translate(cx.toFloat(), cy.toFloat());
|
||||||
cy - targetSize / 2,
|
tf.scale(scale, scale);
|
||||||
bmp,
|
tf.translate(-bmpW.toFloat() / 2.0, -bmpH.toFloat() / 2.0);
|
||||||
{ :scaleX => scale, :scaleY => scale }
|
dc.drawBitmap2(0, 0, bmp, { :transform => tf });
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||