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>
|
||||
<bitmap id="LauncherIcon" filename="launcher_icon.svg"/>
|
||||
<bitmap id="IconHistory" filename="icon_history.svg" automaticPalette="false"/>
|
||||
<bitmap id="IconEvent" filename="icon_event.svg" automaticPalette="false"/>
|
||||
<bitmap id="IconStart" filename="icon_start.svg" automaticPalette="false"/>
|
||||
<bitmap id="IconEnd" filename="icon_end.svg" automaticPalette="false"/>
|
||||
<bitmap id="IconArrival" filename="icon_arrival.svg" automaticPalette="false"/>
|
||||
<bitmap id="IconArrest" filename="icon_arrest.svg" automaticPalette="false"/>
|
||||
<bitmap id="IconForce" filename="icon_force.svg" automaticPalette="false"/>
|
||||
<bitmap id="IconEvidence" filename="icon_evidence.svg" automaticPalette="false"/>
|
||||
<bitmap id="IconSighting" filename="icon_sighting.svg" automaticPalette="false"/>
|
||||
<bitmap id="IconDelete" filename="icon_delete.svg" automaticPalette="false"/>
|
||||
<bitmap id="LauncherIcon" filename="launcher_icon.png"/>
|
||||
<bitmap id="IconHistory" filename="icon_history.png" automaticPalette="false"/>
|
||||
<bitmap id="IconEvent" filename="icon_event.png" automaticPalette="false"/>
|
||||
<bitmap id="IconStart" filename="icon_start.png" automaticPalette="false"/>
|
||||
<bitmap id="IconEnd" filename="icon_end.png" automaticPalette="false"/>
|
||||
<bitmap id="IconArrival" filename="icon_arrival.png" automaticPalette="false"/>
|
||||
<bitmap id="IconArrest" filename="icon_arrest.png" automaticPalette="false"/>
|
||||
<bitmap id="IconForce" filename="icon_force.png" automaticPalette="false"/>
|
||||
<bitmap id="IconEvidence" filename="icon_evidence.png" automaticPalette="false"/>
|
||||
<bitmap id="IconSighting" filename="icon_sighting.png" automaticPalette="false"/>
|
||||
<bitmap id="IconDelete" filename="icon_delete.png" automaticPalette="false"/>
|
||||
</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;
|
||||
|
||||
// --- GPS ------------------------------------------------------------
|
||||
const GPS_TIMEOUT_MS = 10000;
|
||||
const GPS_TARGET_ACCURACY_M = 5;
|
||||
const GPS_TIMEOUT_MS = 30000;
|
||||
|
||||
// --- Menu ring -----------------------------------------------------
|
||||
// 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 accuracy as Float or Null;
|
||||
public var address as String or Null;
|
||||
public var zip as String or Null;
|
||||
|
||||
function initialize(type as String, timestamp as Number,
|
||||
lat as Float or Null, lon as Float or Null,
|
||||
|
|
@ -20,6 +21,7 @@ class Event {
|
|||
self.lon = lon;
|
||||
self.accuracy = accuracy;
|
||||
self.address = null;
|
||||
self.zip = null;
|
||||
}
|
||||
|
||||
function toDict() as Dictionary {
|
||||
|
|
@ -30,9 +32,8 @@ class Event {
|
|||
"lon" => lon,
|
||||
"acc" => accuracy
|
||||
};
|
||||
if (address != null) {
|
||||
d["addr"] = address;
|
||||
}
|
||||
if (address != null) { d["addr"] = address; }
|
||||
if (zip != null) { d["zip"] = zip; }
|
||||
return d;
|
||||
}
|
||||
|
||||
|
|
@ -44,9 +45,8 @@ class Event {
|
|||
d["lon"] as Float or Null,
|
||||
d["acc"] as Float or Null
|
||||
);
|
||||
if (d.hasKey("addr")) {
|
||||
evt.address = d["addr"] as String or Null;
|
||||
}
|
||||
if (d.hasKey("addr")) { evt.address = d["addr"] as String or Null; }
|
||||
if (d.hasKey("zip")) { evt.zip = d["zip"] as String or Null; }
|
||||
return evt;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@ import Toybox.Communications;
|
|||
import Toybox.Lang;
|
||||
|
||||
// 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 {
|
||||
|
||||
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 _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;
|
||||
_lat = lat;
|
||||
_lon = lon;
|
||||
|
|
@ -28,16 +28,13 @@ 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;
|
||||
}
|
||||
|
|
@ -45,7 +42,6 @@ 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) {
|
||||
|
|
@ -54,31 +50,38 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"];
|
||||
if (street == null) { street = props["name"]; }
|
||||
if (street == null) {
|
||||
_callback.invoke(null);
|
||||
return;
|
||||
}
|
||||
|
||||
var parts = "";
|
||||
if (street != null) {
|
||||
parts = street as String;
|
||||
var addr = street as String;
|
||||
var number = props["housenumber"];
|
||||
if (number != null) {
|
||||
parts = parts + " " + number;
|
||||
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;
|
||||
}
|
||||
return parts.equals("") ? "Unbekannt" : parts;
|
||||
result["zip"] = zipCity;
|
||||
}
|
||||
_callback.invoke(result);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,17 +47,16 @@ class GpsService {
|
|||
if (info == null || info.position == null) { return; }
|
||||
|
||||
var degrees = info.position.toDegrees();
|
||||
var acc = (info.accuracy != null) ? info.accuracy : null;
|
||||
var quality = (info.accuracy != null) ? info.accuracy : 0;
|
||||
|
||||
// Always keep the best quality fix.
|
||||
if (_bestFix == null || quality >= (_bestFix["q"] as Number)) {
|
||||
_bestFix = {
|
||||
"lat" => degrees[0].toFloat(),
|
||||
"lon" => degrees[1].toFloat(),
|
||||
"acc" => (acc != null) ? acc.toFloat() : null
|
||||
"acc" => quality.toFloat(),
|
||||
"q" => quality
|
||||
};
|
||||
|
||||
// Good enough → stop early.
|
||||
if (acc != null && acc <= Config.GPS_TARGET_ACCURACY_M) {
|
||||
_finish(_bestFix);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ class HistoryView extends WatchUi.View {
|
|||
var evt = _events[_index];
|
||||
// Use cached address from event if available.
|
||||
if (evt.address != null) {
|
||||
_addressCache[_index] = evt.address;
|
||||
_addressCache[_index] = { "addr" => evt.address, "zip" => evt.zip };
|
||||
return;
|
||||
}
|
||||
if (evt.lat == null || evt.lon == null) { return; }
|
||||
|
|
@ -76,13 +76,14 @@ class HistoryView extends WatchUi.View {
|
|||
_geocoder.start();
|
||||
}
|
||||
|
||||
function _onAddress(address as String or Null) as Void {
|
||||
_addressCache[_index] = address;
|
||||
function _onAddress(result as Dictionary or Null) as Void {
|
||||
_addressCache[_index] = result;
|
||||
_addressLoading = false;
|
||||
// Persist address in the event so the glance can show it.
|
||||
if (address != null && _index < _events.size()) {
|
||||
// Persist in the event so the glance can show it.
|
||||
if (result != null && _index < _events.size()) {
|
||||
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);
|
||||
}
|
||||
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 {
|
||||
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.
|
||||
var label = _eventLabel(evt.type);
|
||||
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);
|
||||
// Collect lines to draw.
|
||||
var lines = [] as Array<Array>; // [text, color]
|
||||
|
||||
// Date + time.
|
||||
dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
|
||||
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);
|
||||
// 1: Event type (colored).
|
||||
lines.add([_eventLabel(evt.type), _eventColor(evt.type)]);
|
||||
|
||||
// Address / coordinates / status.
|
||||
var locStr = _locationString(evt);
|
||||
dc.drawText(cx, midY + lineH / 2, fontDetail, locStr,
|
||||
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
|
||||
// 2: Date + time.
|
||||
lines.add([_formatTimestamp(evt.timestamp), Config.COLOR_FG]);
|
||||
|
||||
// Counter (e.g. "3/7").
|
||||
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 {
|
||||
// 3: Address or status.
|
||||
if (evt.lat == null || evt.lon == null) {
|
||||
return WatchUi.loadResource(Rez.Strings.history_no_gps) as String;
|
||||
}
|
||||
if (_addressCache.hasKey(_index)) {
|
||||
var cached = _addressCache[_index];
|
||||
lines.add([WatchUi.loadResource(Rez.Strings.history_no_gps) as String, Config.COLOR_FG]);
|
||||
} else if (_addressCache.hasKey(_index)) {
|
||||
var cached = _addressCache[_index] as Dictionary or 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);
|
||||
}
|
||||
if (_addressLoading) {
|
||||
return WatchUi.loadResource(Rez.Strings.history_loading_address) as String;
|
||||
} else if (_addressLoading) {
|
||||
lines.add([WatchUi.loadResource(Rez.Strings.history_loading_address) as String, 0x888888]);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -67,6 +67,11 @@ class MenuView extends WatchUi.View {
|
|||
var isSelected = (i == _selectedIndex);
|
||||
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 bmp = _bitmaps[iconId] as BitmapResource;
|
||||
if (bmp != null) {
|
||||
|
|
@ -91,12 +96,12 @@ class MenuView extends WatchUi.View {
|
|||
cx as Number, cy as Number,
|
||||
targetSize as Number) as Void {
|
||||
var bmpW = bmp.getWidth();
|
||||
var bmpH = bmp.getHeight();
|
||||
var scale = targetSize.toFloat() / bmpW.toFloat();
|
||||
dc.drawBitmap2(
|
||||
cx - targetSize / 2,
|
||||
cy - targetSize / 2,
|
||||
bmp,
|
||||
{ :scaleX => scale, :scaleY => scale }
|
||||
);
|
||||
var tf = new Graphics.AffineTransform();
|
||||
tf.translate(cx.toFloat(), cy.toFloat());
|
||||
tf.scale(scale, scale);
|
||||
tf.translate(-bmpW.toFloat() / 2.0, -bmpH.toFloat() / 2.0);
|
||||
dc.drawBitmap2(0, 0, bmp, { :transform => tf });
|
||||
}
|
||||
}
|
||||
|
|
|
|||