diff --git a/manifest.xml b/manifest.xml
index 04c92ee..6496c7b 100644
--- a/manifest.xml
+++ b/manifest.xml
@@ -9,7 +9,9 @@
-
+
+
+
deu
diff --git a/resources/strings/strings.xml b/resources/strings/strings.xml
index 1ba4c4d..e5ebf91 100644
--- a/resources/strings/strings.xml
+++ b/resources/strings/strings.xml
@@ -1,10 +1,10 @@
Einsatzprotokoll
-
- Standort wird bestimmt
- Standort konnte nicht bestimmt werden
- Fehler beim Speichern
+
+ Standort wird|bestimmt
+ Standort konnte|nicht bestimmt|werden
+ Fehler beim|Speichern
Keine Einträge
diff --git a/source/ErrorView.mc b/source/ErrorView.mc
new file mode 100644
index 0000000..0376040
--- /dev/null
+++ b/source/ErrorView.mc
@@ -0,0 +1,60 @@
+import Toybox.Attention;
+import Toybox.Graphics;
+import Toybox.Lang;
+import Toybox.Timer;
+import Toybox.WatchUi;
+
+// Red warning flash when GPS acquisition fails. 3× vibration + German
+// error message for Config.ERROR_DISPLAY_MS, then closes the app.
+class ErrorView extends WatchUi.View {
+
+ private var _timer as Timer.Timer;
+
+ function initialize() {
+ View.initialize();
+ _timer = new Timer.Timer();
+ }
+
+ function onShow() as Void {
+ if (Attention has :vibrate) {
+ var profile = [
+ new Attention.VibeProfile(75, 250),
+ new Attention.VibeProfile(0, 150),
+ new Attention.VibeProfile(75, 250),
+ new Attention.VibeProfile(0, 150),
+ new Attention.VibeProfile(75, 250)
+ ] as Array;
+ Attention.vibrate(profile);
+ }
+ _timer.start(method(:_dismiss), Config.ERROR_DISPLAY_MS, false);
+ }
+
+ function onHide() as Void {
+ _timer.stop();
+ }
+
+ function _dismiss() as Void {
+ System.exit();
+ }
+
+ function onUpdate(dc as Dc) as Void {
+ dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
+ dc.clear();
+
+ var cx = LayoutMetrics.centerX(dc);
+ var cy = LayoutMetrics.centerY(dc);
+ var min = LayoutMetrics.minDim(dc);
+ var iconRadius = (min * 0.1).toNumber();
+ var iconCy = (min * 0.25).toNumber();
+
+ dc.setColor(Config.COLOR_ERROR, Config.COLOR_BG);
+ dc.fillCircle(cx, iconCy, iconRadius);
+ dc.setColor(Config.COLOR_BG, Config.COLOR_ERROR);
+ dc.drawText(cx, iconCy, Graphics.FONT_MEDIUM, "!",
+ Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
+
+ dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
+ var textCy = cy + (min * 0.08).toNumber();
+ TextUtils.drawResourceCentered(dc, Rez.Strings.error_gps, cx, textCy, Graphics.FONT_TINY);
+ }
+}
diff --git a/source/GpsService.mc b/source/GpsService.mc
new file mode 100644
index 0000000..a9cf301
--- /dev/null
+++ b/source/GpsService.mc
@@ -0,0 +1,60 @@
+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.
+class GpsService {
+
+ private var _callback as Method(result as Dictionary or Null) as Void;
+ private var _timer 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();
+ }
+
+ function start() as Void {
+ Position.enableLocationEvents(
+ Position.LOCATION_CONTINUOUS,
+ method(:_onPosition)
+ );
+ _timer.start(method(:_onTimeout), Config.GPS_TIMEOUT_MS, false);
+ }
+
+ function _onPosition(info as Position.Info) as Void {
+ if (_finished) { return; }
+ if (info == null || info.position == null) { return; }
+
+ var degrees = info.position.toDegrees();
+ var acc = (info.accuracy != null) ? info.accuracy.toFloat() : null;
+
+ _bestFix = {
+ "lat" => degrees[0].toFloat(),
+ "lon" => degrees[1].toFloat(),
+ "acc" => acc
+ };
+
+ // Good enough → stop early.
+ if (acc != null && acc <= Config.GPS_TARGET_ACCURACY_M) {
+ _finish(_bestFix);
+ }
+ }
+
+ function _onTimeout() as Void {
+ _finish(_bestFix);
+ }
+
+ function _finish(result as Dictionary or Null) as Void {
+ if (_finished) { return; }
+ _finished = true;
+ _timer.stop();
+ Position.enableLocationEvents(Position.LOCATION_DISABLE, method(:_onPosition));
+ _callback.invoke(result);
+ }
+}
diff --git a/source/LoadingView.mc b/source/LoadingView.mc
new file mode 100644
index 0000000..edcf697
--- /dev/null
+++ b/source/LoadingView.mc
@@ -0,0 +1,91 @@
+import Toybox.Graphics;
+import Toybox.Lang;
+import Toybox.Math;
+import Toybox.Time;
+import Toybox.Timer;
+import Toybox.WatchUi;
+
+// Full-screen loading state shown while GpsService is acquiring a
+// fix. Draws a circular progress bar along the display edge plus
+// the German prompt "Standort wird bestimmt" in the center.
+class LoadingView extends WatchUi.View {
+
+ private var _eventType as String;
+ private var _gps as GpsService or Null;
+ private var _animTimer as Timer.Timer;
+ private var _startMs as Number = 0;
+
+ function initialize(eventType as String) {
+ View.initialize();
+ _eventType = eventType;
+ _animTimer = new Timer.Timer();
+ }
+
+ function onShow() as Void {
+ _startMs = _nowMs();
+ _animTimer.start(method(:_tick), 50, true);
+ _gps = new GpsService(method(:_onGpsResult));
+ _gps.start();
+ }
+
+ function onHide() as Void {
+ _animTimer.stop();
+ }
+
+ function _tick() as Void {
+ WatchUi.requestUpdate();
+ }
+
+ function _onGpsResult(result as Dictionary or Null) as Void {
+ _animTimer.stop();
+ var now = Time.now().value();
+ if (result != null) {
+ var event = new Event(
+ _eventType,
+ now,
+ result["lat"] as Float or Null,
+ result["lon"] as Float or Null,
+ result["acc"] as Float or Null
+ );
+ EventStore.add(event);
+ WatchUi.switchToView(new SuccessView(), null, WatchUi.SLIDE_IMMEDIATE);
+ } else {
+ // No usable fix at all → save event with null coords (per
+ // spec), but still surface an error screen so the user
+ // knows GPS did not lock.
+ var event = new Event(_eventType, now, null, null, null);
+ EventStore.add(event);
+ WatchUi.switchToView(new ErrorView(), null, WatchUi.SLIDE_IMMEDIATE);
+ }
+ }
+
+ function onUpdate(dc as Dc) as Void {
+ dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
+ dc.clear();
+
+ var cx = LayoutMetrics.centerX(dc);
+ var cy = LayoutMetrics.centerY(dc);
+ var radius = LayoutMetrics.edgeArcRadius(dc);
+
+ // Circular progress — white arc that grows with elapsed time.
+ var elapsed = _nowMs() - _startMs;
+ var progress = elapsed.toFloat() / Config.GPS_TIMEOUT_MS.toFloat();
+ if (progress > 1.0) { progress = 1.0; }
+
+ dc.setPenWidth(LayoutMetrics.edgeArcPenWidth(dc));
+ dc.setColor(Config.COLOR_ACCENT, Config.COLOR_BG);
+ var sweep = progress * 360.0;
+ // drawArc uses degrees with 0° = 3 o'clock and counter-clockwise.
+ // Start at 12 o'clock and sweep clockwise.
+ var startDeg = 90;
+ var endDeg = 90 - sweep;
+ dc.drawArc(cx, cy, radius, Graphics.ARC_CLOCKWISE, startDeg, endDeg);
+
+ dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
+ TextUtils.drawResourceCentered(dc, Rez.Strings.loading_gps, cx, cy, Graphics.FONT_TINY);
+ }
+
+ private function _nowMs() as Number {
+ return System.getTimer();
+ }
+}
diff --git a/source/MenuDelegate.mc b/source/MenuDelegate.mc
index 29aea85..ae3ea00 100644
--- a/source/MenuDelegate.mc
+++ b/source/MenuDelegate.mc
@@ -1,9 +1,9 @@
import Toybox.Lang;
import Toybox.WatchUi;
-// Routes menu input. UP/DOWN rotate the ring; START/STOP selects.
-// Dispatch of event-type keys is wired up in later phases — for now
-// each selection just logs and no-ops.
+// Routes menu input. UP/DOWN rotate the ring. START/STOP dispatches:
+// - event types (arrest, start, …) push the GPS LoadingView
+// - history / delete are stubbed until later phases
class MenuDelegate extends WatchUi.BehaviorDelegate {
private var _view as MenuView;
@@ -25,7 +25,18 @@ class MenuDelegate extends WatchUi.BehaviorDelegate {
function onSelect() as Boolean {
var item = _view.selectedItem();
- Logger.log("menu.select: " + (item[:key] as String));
+ var key = item[:key] as String;
+
+ if (key.equals(Config.ACTION_HISTORY)) {
+ // Phase 4
+ return true;
+ }
+ if (key.equals(Config.ACTION_DELETE)) {
+ // Phase 5
+ return true;
+ }
+
+ WatchUi.pushView(new LoadingView(key), null, WatchUi.SLIDE_IMMEDIATE);
return true;
}
}
diff --git a/source/MenuView.mc b/source/MenuView.mc
index c112019..adbb1fd 100644
--- a/source/MenuView.mc
+++ b/source/MenuView.mc
@@ -82,14 +82,7 @@ class MenuView extends WatchUi.View {
var lines = _items[_selectedIndex][:lines] as Array;
dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
- var font = Graphics.FONT_TINY;
- var lineHeight = dc.getFontHeight(font);
- var totalHeight = lines.size() * lineHeight;
- var startY = cy - totalHeight / 2 + lineHeight / 2;
- for (var j = 0; j < lines.size(); j++) {
- dc.drawText(cx, startY + j * lineHeight, font, lines[j],
- Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
- }
+ TextUtils.drawCentered(dc, lines, cx, cy, Graphics.FONT_TINY);
}
// Scales a bitmap to the requested pixel size and draws it centered
diff --git a/source/SuccessView.mc b/source/SuccessView.mc
new file mode 100644
index 0000000..ecb6bd9
--- /dev/null
+++ b/source/SuccessView.mc
@@ -0,0 +1,52 @@
+import Toybox.Attention;
+import Toybox.Graphics;
+import Toybox.Lang;
+import Toybox.Timer;
+import Toybox.WatchUi;
+
+// Green checkmark flash after a successful event save. Vibrates
+// briefly, holds for Config.SUCCESS_DISPLAY_MS, then closes the app.
+class SuccessView extends WatchUi.View {
+
+ private var _timer as Timer.Timer;
+
+ function initialize() {
+ View.initialize();
+ _timer = new Timer.Timer();
+ }
+
+ function onShow() as Void {
+ if (Attention has :vibrate) {
+ Attention.vibrate([new Attention.VibeProfile(75, 200)] as Array);
+ }
+ _timer.start(method(:_dismiss), Config.SUCCESS_DISPLAY_MS, false);
+ }
+
+ function onHide() as Void {
+ _timer.stop();
+ }
+
+ function _dismiss() as Void {
+ System.exit();
+ }
+
+ function onUpdate(dc as Dc) as Void {
+ dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
+ dc.clear();
+
+ var cx = LayoutMetrics.centerX(dc);
+ var cy = LayoutMetrics.centerY(dc);
+ var min = LayoutMetrics.minDim(dc);
+ var size = (min * 0.35).toNumber();
+
+ dc.setColor(Config.COLOR_SUCCESS, Config.COLOR_BG);
+ dc.setPenWidth((min * 0.035).toNumber());
+
+ // Checkmark: two line segments forming a tick.
+ var left = cx - size / 2;
+ var right = cx + size / 2;
+ var midX = cx - size / 6;
+ dc.drawLine(left, cy, midX, cy + size / 3);
+ dc.drawLine(midX, cy + size / 3, right, cy - size / 2);
+ }
+}
diff --git a/source/TextUtils.mc b/source/TextUtils.mc
new file mode 100644
index 0000000..8634450
--- /dev/null
+++ b/source/TextUtils.mc
@@ -0,0 +1,46 @@
+import Toybox.Graphics;
+import Toybox.Lang;
+import Toybox.WatchUi;
+
+// Text layout helpers. CIQ's drawText does not wrap, so callers split
+// a string by a separator and hand us an Array which we
+// render vertically centered around (cx, cy).
+module TextUtils {
+
+ const LINE_SEPARATOR = "|";
+
+ function splitLines(text as String) as Array {
+ var result = [] as Array;
+ var current = "";
+ for (var i = 0; i < text.length(); i++) {
+ var c = text.substring(i, i + 1);
+ if (c.equals(LINE_SEPARATOR)) {
+ result.add(current);
+ current = "";
+ } else {
+ current += c;
+ }
+ }
+ result.add(current);
+ return result;
+ }
+
+ function drawCentered(dc as Dc, lines as Array,
+ cx as Number, cy as Number, font as Graphics.FontType) as Void {
+ var lineHeight = dc.getFontHeight(font);
+ var totalHeight = lines.size() * lineHeight;
+ var startY = cy - totalHeight / 2 + lineHeight / 2;
+ for (var j = 0; j < lines.size(); j++) {
+ dc.drawText(cx, startY + j * lineHeight, font, lines[j],
+ Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
+ }
+ }
+
+ // Convenience: load a string resource and split + draw in one call.
+ function drawResourceCentered(dc as Dc, resourceId as ResourceId,
+ cx as Number, cy as Number,
+ font as Graphics.FontType) as Void {
+ var text = WatchUi.loadResource(resourceId) as String;
+ drawCentered(dc, splitLines(text), cx, cy, font);
+ }
+}