diff --git a/manifest.xml b/manifest.xml
index 6496c7b..7dd4ae5 100644
--- a/manifest.xml
+++ b/manifest.xml
@@ -11,6 +11,7 @@
+
deu
diff --git a/resources/strings/strings.xml b/resources/strings/strings.xml
index e5ebf91..182e7b7 100644
--- a/resources/strings/strings.xml
+++ b/resources/strings/strings.xml
@@ -7,9 +7,10 @@
Fehler beim|Speichern
- Keine Einträge
- Adresse wird geladen…
- Verbinde Handy für Adresse
+ Keine|Einträge
+ Adresse wird|geladen…
+ Verbinde Handy|für Adresse
+ Kein Standort
Halten zum Löschen
diff --git a/source/GeocodingService.mc b/source/GeocodingService.mc
new file mode 100644
index 0000000..9fe4c4d
--- /dev/null
+++ b/source/GeocodingService.mc
@@ -0,0 +1,83 @@
+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.
+class GeocodingService {
+
+ private var _callback as Method(address as String 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 = callback;
+ _lat = lat;
+ _lon = lon;
+ }
+
+ function start() as Void {
+ var url = Config.PHOTON_URL;
+ var params = { "lon" => _lon, "lat" => _lat, "limit" => 1 };
+ var options = {
+ :method => Communications.HTTP_REQUEST_METHOD_GET,
+ :responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON,
+ :headers => { "Accept" => "application/json" }
+ };
+ Communications.makeWebRequest(url, params, options, method(:_onResponse));
+ }
+
+ function _onResponse(responseCode as Number, data as Dictionary or String or Null) as Void {
+ if (responseCode != 200 || data == null || !(data instanceof Dictionary)) {
+ _callback.invoke(null);
+ return;
+ }
+
+ var features = data["features"];
+ if (features == null || !(features instanceof Array) || features.size() == 0) {
+ _callback.invoke(null);
+ return;
+ }
+
+ var feature = features[0] as Dictionary;
+ var props = feature["properties"] as Dictionary;
+ var geometry = feature["geometry"] as Dictionary;
+
+ // Haversine check: only use address if result is within threshold.
+ if (geometry != null) {
+ var coords = geometry["coordinates"] as Array;
+ if (coords != null && coords.size() >= 2) {
+ var rLon = (coords[0] as Double).toFloat();
+ var rLat = (coords[1] as Double).toFloat();
+ var dist = Haversine.distance(_lat, _lon, rLat, rLon);
+ if (dist > Config.ADDRESS_MAX_DISTANCE_M) {
+ _callback.invoke(null);
+ return;
+ }
+ }
+ }
+
+ _callback.invoke(_formatAddress(props));
+ }
+
+ 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) {
+ parts = street as String;
+ if (number != null) {
+ parts = parts + " " + number;
+ }
+ }
+ if (city != null) {
+ if (!parts.equals("")) {
+ parts = parts + ", ";
+ }
+ parts = parts + city;
+ }
+ return parts.equals("") ? "Unbekannt" : parts;
+ }
+}
diff --git a/source/GpsService.mc b/source/GpsService.mc
index a9cf301..6631f70 100644
--- a/source/GpsService.mc
+++ b/source/GpsService.mc
@@ -2,21 +2,22 @@ import Toybox.Lang;
import Toybox.Position;
import Toybox.Timer;
-// Single-shot GPS acquisition with a hard timeout. Caller supplies
-// a callback that fires exactly once with either a position fix or
-// null (if nothing usable was received before the timeout). Accuracy
-// shortcut: if the fix already hits Config.GPS_TARGET_ACCURACY_M we
-// stop waiting even if the timeout hasn't expired.
+// Single-shot GPS acquisition with a hard timeout. Uses both
+// enableLocationEvents (fires on real hardware) and a polling
+// fallback via Position.getInfo() (works in the simulator where
+// "Set Position" doesn't trigger location events).
class GpsService {
private var _callback as Method(result as Dictionary or Null) as Void;
private var _timer as Timer.Timer;
+ private var _pollTimer as Timer.Timer;
private var _finished as Boolean = false;
private var _bestFix as Dictionary or Null = null;
function initialize(callback as Method(result as Dictionary or Null) as Void) {
_callback = callback;
_timer = new Timer.Timer();
+ _pollTimer = new Timer.Timer();
}
function start() as Void {
@@ -24,20 +25,34 @@ class GpsService {
Position.LOCATION_CONTINUOUS,
method(:_onPosition)
);
+ // Poll Position.getInfo() every 500ms as fallback for simulator.
+ _pollTimer.start(method(:_poll), 500, true);
_timer.start(method(:_onTimeout), Config.GPS_TIMEOUT_MS, false);
}
+ function _poll() as Void {
+ if (_finished) { return; }
+ var info = Position.getInfo();
+ if (info != null && info.position != null) {
+ _processInfo(info);
+ }
+ }
+
function _onPosition(info as Position.Info) as Void {
if (_finished) { return; }
+ _processInfo(info);
+ }
+
+ private function _processInfo(info as Position.Info) as Void {
if (info == null || info.position == null) { return; }
var degrees = info.position.toDegrees();
- var acc = (info.accuracy != null) ? info.accuracy.toFloat() : null;
+ var acc = (info.accuracy != null) ? info.accuracy : null;
_bestFix = {
"lat" => degrees[0].toFloat(),
"lon" => degrees[1].toFloat(),
- "acc" => acc
+ "acc" => (acc != null) ? acc.toFloat() : null
};
// Good enough → stop early.
@@ -54,6 +69,7 @@ class GpsService {
if (_finished) { return; }
_finished = true;
_timer.stop();
+ _pollTimer.stop();
Position.enableLocationEvents(Position.LOCATION_DISABLE, method(:_onPosition));
_callback.invoke(result);
}
diff --git a/source/HistoryDelegate.mc b/source/HistoryDelegate.mc
new file mode 100644
index 0000000..80eabf5
--- /dev/null
+++ b/source/HistoryDelegate.mc
@@ -0,0 +1,29 @@
+import Toybox.Lang;
+import Toybox.WatchUi;
+
+// Input handler for HistoryView. UP/DOWN scroll through events,
+// BACK returns to the menu.
+class HistoryDelegate extends WatchUi.BehaviorDelegate {
+
+ private var _view as HistoryView;
+
+ function initialize(view as HistoryView) {
+ BehaviorDelegate.initialize();
+ _view = view;
+ }
+
+ function onNextPage() as Boolean {
+ _view.showNext();
+ return true;
+ }
+
+ function onPreviousPage() as Boolean {
+ _view.showPrev();
+ return true;
+ }
+
+ function onBack() as Boolean {
+ WatchUi.popView(WatchUi.SLIDE_RIGHT);
+ return true;
+ }
+}
diff --git a/source/HistoryView.mc b/source/HistoryView.mc
new file mode 100644
index 0000000..5a2ff57
--- /dev/null
+++ b/source/HistoryView.mc
@@ -0,0 +1,226 @@
+import Toybox.Graphics;
+import Toybox.Lang;
+import Toybox.Time;
+import Toybox.Time.Gregorian;
+import Toybox.WatchUi;
+
+// Scrollable history of recorded events. 3-section layout:
+// top 15%: previous entry icon + up arrow
+// mid 70%: current entry details (type, datetime, address/coords)
+// bot 15%: next entry icon + down arrow
+class HistoryView extends WatchUi.View {
+
+ private var _events as Array;
+ private var _index as Number = 0;
+ private var _bitmaps as Dictionary = {};
+ private var _addressCache as Dictionary = {}; // index → String or Null
+ private var _addressLoading as Boolean = false;
+ private var _geocoder as GeocodingService or Null = null;
+
+ function initialize() {
+ View.initialize();
+ _events = EventStore.getAll();
+ // Start at newest entry.
+ if (_events.size() > 0) {
+ _index = _events.size() - 1;
+ }
+ }
+
+ function onLayout(dc as Dc) as Void {
+ var items = Config.menuItems();
+ for (var i = 0; i < items.size(); i++) {
+ var iconId = items[i][:icon];
+ _bitmaps[items[i][:key]] = WatchUi.loadResource(iconId) as BitmapResource;
+ }
+ }
+
+ function eventCount() as Number {
+ return _events.size();
+ }
+
+ function showNext() as Void {
+ if (_events.size() == 0) { return; }
+ _index = (_index + 1) % _events.size();
+ _addressLoading = false;
+ _requestAddress();
+ WatchUi.requestUpdate();
+ }
+
+ function showPrev() as Void {
+ if (_events.size() == 0) { return; }
+ _index = (_index - 1 + _events.size()) % _events.size();
+ _addressLoading = false;
+ _requestAddress();
+ WatchUi.requestUpdate();
+ }
+
+ function onShow() as Void {
+ _requestAddress();
+ }
+
+ private function _requestAddress() as Void {
+ if (_events.size() == 0) { return; }
+ if (_addressCache.hasKey(_index)) { return; }
+ var evt = _events[_index];
+ if (evt.lat == null || evt.lon == null) { return; }
+ _addressLoading = true;
+ _geocoder = new GeocodingService(
+ evt.lat as Float, evt.lon as Float,
+ method(:_onAddress)
+ );
+ _geocoder.start();
+ }
+
+ function _onAddress(address as String or Null) as Void {
+ _addressCache[_index] = address;
+ _addressLoading = false;
+ WatchUi.requestUpdate();
+ }
+
+ function onUpdate(dc as Dc) as Void {
+ dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
+ dc.clear();
+
+ if (_events.size() == 0) {
+ var cx = LayoutMetrics.centerX(dc);
+ var cy = LayoutMetrics.centerY(dc);
+ TextUtils.drawResourceCentered(dc, Rez.Strings.history_empty, cx, cy, Graphics.FONT_MEDIUM);
+ return;
+ }
+
+ var cx = LayoutMetrics.centerX(dc);
+ var topH = LayoutMetrics.topSectionHeight(dc);
+ var botY = LayoutMetrics.bottomSectionY(dc);
+ var screenH = dc.getHeight();
+
+ _drawTopSection(dc, cx, topH);
+ _drawMiddleSection(dc, cx, topH, botY);
+ _drawBottomSection(dc, cx, botY, screenH);
+ }
+
+ private function _drawTopSection(dc as Dc, cx as Number, topH as Number) as Void {
+ if (_events.size() <= 1) { return; }
+ var midY = topH / 2;
+ var arrowSize = (topH * 0.2).toNumber();
+ dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
+ dc.setPenWidth(2);
+ dc.drawLine(cx, midY - arrowSize, cx, midY + arrowSize);
+ dc.drawLine(cx - arrowSize, midY, cx, midY - arrowSize);
+ dc.drawLine(cx + arrowSize, midY, cx, midY - arrowSize);
+ }
+
+ private function _drawBottomSection(dc as Dc, cx as Number, botY as Number, screenH as Number) as Void {
+ if (_events.size() <= 1) { return; }
+ var sectionH = screenH - botY;
+ var midY = botY + sectionH / 2;
+ var arrowSize = (sectionH * 0.2).toNumber();
+ dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
+ dc.setPenWidth(2);
+ dc.drawLine(cx, midY - arrowSize, cx, midY + arrowSize);
+ dc.drawLine(cx - arrowSize, midY, cx, midY + arrowSize);
+ dc.drawLine(cx + arrowSize, midY, cx, midY + arrowSize);
+ }
+
+ 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;
+
+ // 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);
+
+ // 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);
+
+ // Address / coordinates / status.
+ var locStr = _locationString(evt);
+ dc.drawText(cx, midY + lineH / 2, fontDetail, locStr,
+ Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
+
+ // 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 {
+ 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];
+ if (cached != null) {
+ return cached as String;
+ }
+ return _formatCoords(evt.lat as Float, evt.lon as Float);
+ }
+ if (_addressLoading) {
+ return WatchUi.loadResource(Rez.Strings.history_loading_address) as String;
+ }
+ return _formatCoords(evt.lat as Float, evt.lon as Float);
+ }
+
+ private function _formatCoords(lat as Float, lon as Float) as String {
+ return lat.format("%.4f") + ", " + lon.format("%.4f");
+ }
+
+ 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")
+ ]);
+ }
+
+ private function _eventLabel(key as String) as String {
+ var items = Config.menuItems();
+ for (var i = 0; i < items.size(); i++) {
+ if ((items[i][:key] as String).equals(key)) {
+ var lines = items[i][:lines] as Array;
+ var result = "";
+ for (var j = 0; j < lines.size(); j++) {
+ if (j > 0) { result += ""; }
+ result += lines[j];
+ }
+ return result;
+ }
+ }
+ return key;
+ }
+
+ private function _eventColor(key as String) as Number {
+ var items = Config.menuItems();
+ for (var i = 0; i < items.size(); i++) {
+ if ((items[i][:key] as String).equals(key)) {
+ return items[i][:color] as Number;
+ }
+ }
+ return Config.COLOR_FG;
+ }
+
+ private function _drawEventIcon(dc as Dc, key as String, cx as Number, cy as Number, size as Number) as Void {
+ var bmp = _bitmaps[key] as BitmapResource or Null;
+ if (bmp == null) { return; }
+ var bmpW = bmp.getWidth();
+ var scale = size.toFloat() / bmpW.toFloat();
+ dc.drawBitmap2(
+ cx - size / 2, cy - size / 2,
+ bmp,
+ { :scaleX => scale, :scaleY => scale }
+ );
+ }
+}
diff --git a/source/MenuDelegate.mc b/source/MenuDelegate.mc
index ae3ea00..6d9ac4b 100644
--- a/source/MenuDelegate.mc
+++ b/source/MenuDelegate.mc
@@ -28,7 +28,8 @@ class MenuDelegate extends WatchUi.BehaviorDelegate {
var key = item[:key] as String;
if (key.equals(Config.ACTION_HISTORY)) {
- // Phase 4
+ var view = new HistoryView();
+ WatchUi.pushView(view, new HistoryDelegate(view), WatchUi.SLIDE_LEFT);
return true;
}
if (key.equals(Config.ACTION_DELETE)) {