Phase 3: event creation flow (GPS + success / error)
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) <noreply@anthropic.com>
This commit is contained in:
parent
d3494acc0d
commit
025d3007db
9 changed files with 332 additions and 17 deletions
|
|
@ -9,7 +9,9 @@
|
||||||
<iq:product id="fenix7"/>
|
<iq:product id="fenix7"/>
|
||||||
<iq:product id="fr265"/>
|
<iq:product id="fr265"/>
|
||||||
</iq:products>
|
</iq:products>
|
||||||
<iq:permissions/>
|
<iq:permissions>
|
||||||
|
<iq:uses-permission id="Positioning"/>
|
||||||
|
</iq:permissions>
|
||||||
<iq:languages>
|
<iq:languages>
|
||||||
<iq:language>deu</iq:language>
|
<iq:language>deu</iq:language>
|
||||||
</iq:languages>
|
</iq:languages>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
<strings>
|
<strings>
|
||||||
<string id="AppName">Einsatzprotokoll</string>
|
<string id="AppName">Einsatzprotokoll</string>
|
||||||
|
|
||||||
<!-- Loading / result screens -->
|
<!-- Loading / result screens. "|" marks a line break. -->
|
||||||
<string id="loading_gps">Standort wird bestimmt</string>
|
<string id="loading_gps">Standort wird|bestimmt</string>
|
||||||
<string id="error_gps">Standort konnte nicht bestimmt werden</string>
|
<string id="error_gps">Standort konnte|nicht bestimmt|werden</string>
|
||||||
<string id="error_generic">Fehler beim Speichern</string>
|
<string id="error_generic">Fehler beim|Speichern</string>
|
||||||
|
|
||||||
<!-- History -->
|
<!-- History -->
|
||||||
<string id="history_empty">Keine Einträge</string>
|
<string id="history_empty">Keine Einträge</string>
|
||||||
|
|
|
||||||
60
source/ErrorView.mc
Normal file
60
source/ErrorView.mc
Normal file
|
|
@ -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.VibeProfile>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
source/GpsService.mc
Normal file
60
source/GpsService.mc
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
source/LoadingView.mc
Normal file
91
source/LoadingView.mc
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import Toybox.Lang;
|
import Toybox.Lang;
|
||||||
import Toybox.WatchUi;
|
import Toybox.WatchUi;
|
||||||
|
|
||||||
// Routes menu input. UP/DOWN rotate the ring; START/STOP selects.
|
// Routes menu input. UP/DOWN rotate the ring. START/STOP dispatches:
|
||||||
// Dispatch of event-type keys is wired up in later phases — for now
|
// - event types (arrest, start, …) push the GPS LoadingView
|
||||||
// each selection just logs and no-ops.
|
// - history / delete are stubbed until later phases
|
||||||
class MenuDelegate extends WatchUi.BehaviorDelegate {
|
class MenuDelegate extends WatchUi.BehaviorDelegate {
|
||||||
|
|
||||||
private var _view as MenuView;
|
private var _view as MenuView;
|
||||||
|
|
@ -25,7 +25,18 @@ class MenuDelegate extends WatchUi.BehaviorDelegate {
|
||||||
|
|
||||||
function onSelect() as Boolean {
|
function onSelect() as Boolean {
|
||||||
var item = _view.selectedItem();
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,14 +82,7 @@ class MenuView extends WatchUi.View {
|
||||||
|
|
||||||
var lines = _items[_selectedIndex][:lines] as Array<String>;
|
var lines = _items[_selectedIndex][:lines] as Array<String>;
|
||||||
dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
|
dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
|
||||||
var font = Graphics.FONT_TINY;
|
TextUtils.drawCentered(dc, lines, cx, cy, 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scales a bitmap to the requested pixel size and draws it centered
|
// Scales a bitmap to the requested pixel size and draws it centered
|
||||||
|
|
|
||||||
52
source/SuccessView.mc
Normal file
52
source/SuccessView.mc
Normal file
|
|
@ -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<Attention.VibeProfile>);
|
||||||
|
}
|
||||||
|
_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
source/TextUtils.mc
Normal file
46
source/TextUtils.mc
Normal file
|
|
@ -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<String> which we
|
||||||
|
// render vertically centered around (cx, cy).
|
||||||
|
module TextUtils {
|
||||||
|
|
||||||
|
const LINE_SEPARATOR = "|";
|
||||||
|
|
||||||
|
function splitLines(text as String) as Array<String> {
|
||||||
|
var result = [] as Array<String>;
|
||||||
|
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<String>,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue