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