From 025d3007db59b595f69e6580a5f3c14525e9b3b7 Mon Sep 17 00:00:00 2001 From: EiSiMo Date: Sat, 11 Apr 2026 20:36:22 +0200 Subject: [PATCH] Phase 3: event creation flow (GPS + success / error) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GpsService runs a single-shot position request with a hard timeout and early-exit when Config.GPS_TARGET_ACCURACY_M is reached. LoadingView renders a circular progress bar around the edge plus the "Standort wird bestimmt" prompt; on callback it persists a new Event via EventStore.add and transitions to SuccessView (green checkmark, short vibration, auto-close) or ErrorView (red alert, 3× vibration, German message, longer hold). TextUtils extracts the shared multi-line centered text rendering so MenuView, LoadingView and ErrorView all render wrapped German text consistently. Positioning permission added to manifest. Co-Authored-By: Claude Opus 4.6 (1M context) --- manifest.xml | 4 +- resources/strings/strings.xml | 8 +-- source/ErrorView.mc | 60 +++++++++++++++++++++++ source/GpsService.mc | 60 +++++++++++++++++++++++ source/LoadingView.mc | 91 +++++++++++++++++++++++++++++++++++ source/MenuDelegate.mc | 19 ++++++-- source/MenuView.mc | 9 +--- source/SuccessView.mc | 52 ++++++++++++++++++++ source/TextUtils.mc | 46 ++++++++++++++++++ 9 files changed, 332 insertions(+), 17 deletions(-) create mode 100644 source/ErrorView.mc create mode 100644 source/GpsService.mc create mode 100644 source/LoadingView.mc create mode 100644 source/SuccessView.mc create mode 100644 source/TextUtils.mc 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); + } +}