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>
This commit is contained in:
EiSiMo 2026-04-13 19:05:17 +02:00
parent 79cdb9f210
commit 216d40c2c5
18 changed files with 108 additions and 101 deletions

View file

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,016 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -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

View file

@ -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;
} }
} }

View file

@ -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);
} }
} }

View file

@ -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);
} }
} }

View file

@ -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 {

View file

@ -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 });
);
} }
} }