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

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;
// --- 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

View file

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

View file

@ -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"];
var parts = "";
if (street != null) {
parts = street as String;
if (number != null) {
parts = parts + " " + number;
}
if (street == null) { street = props["name"]; }
if (street == null) {
_callback.invoke(null);
return;
}
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; }
var degrees = info.position.toDegrees();
var acc = (info.accuracy != null) ? info.accuracy : null;
var quality = (info.accuracy != null) ? info.accuracy : 0;
_bestFix = {
"lat" => degrees[0].toFloat(),
"lon" => degrees[1].toFloat(),
"acc" => (acc != null) ? acc.toFloat() : null
};
// Good enough stop early.
if (acc != null && acc <= Config.GPS_TARGET_ACCURACY_M) {
_finish(_bestFix);
// Always keep the best quality fix.
if (_bestFix == null || quality >= (_bestFix["q"] as Number)) {
_bestFix = {
"lat" => degrees[0].toFloat(),
"lon" => degrees[1].toFloat(),
"acc" => quality.toFloat(),
"q" => quality
};
}
}

View file

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

View file

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