Phase 1: foundation modules

Central Config with all tunables (retention, GPS timeout, colors,
event type keys, menu item metadata). Event / EventStore handle
persistence via Application.Storage with 7-day pruning. Logger
mirrors the same retention. LayoutMetrics and Haversine provide
resolution-independent geometry helpers. German UI strings and
placeholder menu icons landed alongside so the build stays green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
EiSiMo 2026-04-11 19:36:15 +02:00
parent 6f56c337f7
commit 43f970d764
19 changed files with 409 additions and 2 deletions

62
source/Config.mc Normal file
View file

@ -0,0 +1,62 @@
import Toybox.Graphics;
import Toybox.Lang;
// Central configuration. All tunable values live here so they can be
// changed without touching feature code.
module Config {
// --- Retention ------------------------------------------------------
const RETENTION_SEC = 7 * 24 * 60 * 60;
// --- GPS ------------------------------------------------------------
const GPS_TIMEOUT_MS = 10000;
const GPS_TARGET_ACCURACY_M = 5;
// --- Animation / UI timings ----------------------------------------
const SUCCESS_DISPLAY_MS = 2500;
const ERROR_DISPLAY_MS = 5000;
const DELETE_HOLD_MS = 2500;
// --- Address resolution --------------------------------------------
const ADDRESS_MAX_DISTANCE_M = 20;
const PHOTON_URL = "https://photon.komoot.io/reverse";
// --- Colors (AMOLED: black background, white fg) -------------------
const COLOR_BG = 0x000000;
const COLOR_FG = 0xFFFFFF;
const COLOR_SUCCESS = 0x00AA00;
const COLOR_ERROR = 0xFF2222;
const COLOR_DELETE = 0xFF2222;
const COLOR_ACCENT = 0xFFFFFF;
// --- Event type keys ------------------------------------------------
const EVENT_GENERAL = "general";
const EVENT_START = "start";
const EVENT_END = "end";
const EVENT_ARRIVAL = "arrival";
const EVENT_ARREST = "arrest";
const EVENT_FORCE = "force";
const EVENT_EVIDENCE = "evidence";
const EVENT_SIGHTING = "sighting";
// --- Special menu actions ------------------------------------------
const ACTION_HISTORY = "history";
const ACTION_DELETE = "delete";
// Menu item metadata. Order matches display order (index 0 is first
// selected). Each entry: key, drawable id, string id, highlight color.
function menuItems() as Array<Dictionary> {
return [
{ :key => ACTION_HISTORY, :icon => Rez.Drawables.IconHistory, :label => Rez.Strings.menu_history, :color => 0xFFFFFF },
{ :key => EVENT_GENERAL, :icon => Rez.Drawables.IconEvent, :label => Rez.Strings.menu_general, :color => 0xFFAA00 },
{ :key => EVENT_START, :icon => Rez.Drawables.IconStart, :label => Rez.Strings.menu_start, :color => 0x00FF00 },
{ :key => EVENT_END, :icon => Rez.Drawables.IconEnd, :label => Rez.Strings.menu_end, :color => 0x00AAFF },
{ :key => EVENT_ARRIVAL, :icon => Rez.Drawables.IconArrival, :label => Rez.Strings.menu_arrival, :color => 0xFFFF00 },
{ :key => EVENT_ARREST, :icon => Rez.Drawables.IconArrest, :label => Rez.Strings.menu_arrest, :color => 0xFF0088 },
{ :key => EVENT_FORCE, :icon => Rez.Drawables.IconForce, :label => Rez.Strings.menu_force, :color => 0xAA00FF },
{ :key => EVENT_EVIDENCE, :icon => Rez.Drawables.IconEvidence, :label => Rez.Strings.menu_evidence, :color => 0x00FFFF },
{ :key => EVENT_SIGHTING, :icon => Rez.Drawables.IconSighting, :label => Rez.Strings.menu_sighting, :color => 0xFF8800 },
{ :key => ACTION_DELETE, :icon => Rez.Drawables.IconDelete, :label => Rez.Strings.menu_delete, :color => 0xFF2222 }
];
}
}

View file

@ -8,7 +8,10 @@ class EinsatzprotokollApp extends Application.AppBase {
AppBase.initialize();
}
function onStart(state as Dictionary?) as Void {}
function onStart(state as Dictionary?) as Void {
EventStore.pruneOld();
Logger.pruneOld();
}
function onStop(state as Dictionary?) as Void {}

41
source/Event.mc Normal file
View file

@ -0,0 +1,41 @@
import Toybox.Lang;
// Value object for a protocol entry. Serialized as a plain Dictionary
// so Application.Storage can persist it.
class Event {
public var type as String;
public var timestamp as Number; // unix seconds
public var lat as Float or Null;
public var lon as Float or Null;
public var accuracy as Float or Null;
function initialize(type as String, timestamp as Number,
lat as Float or Null, lon as Float or Null,
accuracy as Float or Null) {
self.type = type;
self.timestamp = timestamp;
self.lat = lat;
self.lon = lon;
self.accuracy = accuracy;
}
function toDict() as Dictionary {
return {
"t" => type,
"ts" => timestamp,
"lat" => lat,
"lon" => lon,
"acc" => accuracy
};
}
static function fromDict(d as Dictionary) as Event {
return new Event(
d["t"] as String,
d["ts"] as Number,
d["lat"] as Float or Null,
d["lon"] as Float or Null,
d["acc"] as Float or Null
);
}
}

73
source/EventStore.mc Normal file
View file

@ -0,0 +1,73 @@
import Toybox.Application;
import Toybox.Lang;
import Toybox.Time;
// Persists events in Application.Storage as an array of dictionaries,
// ordered oldest newest. Prunes entries older than Config.RETENTION_SEC
// whenever loaded.
module EventStore {
const KEY = "events";
function getAll() as Array<Event> {
var raw = Application.Storage.getValue(KEY);
if (raw == null || !(raw instanceof Array)) {
return [];
}
var out = [];
for (var i = 0; i < raw.size(); i++) {
out.add(Event.fromDict(raw[i] as Dictionary));
}
return out;
}
function add(event as Event) as Void {
var raw = Application.Storage.getValue(KEY);
var arr = (raw instanceof Array) ? raw : [];
arr.add(event.toDict());
Application.Storage.setValue(KEY, arr);
}
function deleteLast() as Boolean {
var raw = Application.Storage.getValue(KEY);
if (!(raw instanceof Array) || raw.size() == 0) {
return false;
}
raw = raw.slice(0, raw.size() - 1);
Application.Storage.setValue(KEY, raw);
return true;
}
function count() as Number {
var raw = Application.Storage.getValue(KEY);
return (raw instanceof Array) ? raw.size() : 0;
}
function latest() as Event or Null {
var raw = Application.Storage.getValue(KEY);
if (!(raw instanceof Array) || raw.size() == 0) {
return null;
}
return Event.fromDict(raw[raw.size() - 1] as Dictionary);
}
// Drops events with timestamp < (now - retention).
function pruneOld() as Void {
var raw = Application.Storage.getValue(KEY);
if (!(raw instanceof Array) || raw.size() == 0) {
return;
}
var cutoff = Time.now().value() - Config.RETENTION_SEC;
var kept = [];
for (var i = 0; i < raw.size(); i++) {
var d = raw[i] as Dictionary;
var ts = d["ts"];
if (ts != null && (ts as Number) >= cutoff) {
kept.add(d);
}
}
if (kept.size() != raw.size()) {
Application.Storage.setValue(KEY, kept);
}
}
}

25
source/Haversine.mc Normal file
View file

@ -0,0 +1,25 @@
import Toybox.Lang;
import Toybox.Math;
// Great-circle distance between two WGS84 coordinates, in meters.
module Haversine {
const EARTH_RADIUS_M = 6371000.0;
function distance(lat1 as Float, lon1 as Float, lat2 as Float, lon2 as Float) as Float {
var dLat = _toRad(lat2 - lat1);
var dLon = _toRad(lon2 - lon1);
var rLat1 = _toRad(lat1);
var rLat2 = _toRad(lat2);
var a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(rLat1) * Math.cos(rLat2)
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
var c = 2 * Math.asin(Math.sqrt(a));
return (EARTH_RADIUS_M * c).toFloat();
}
function _toRad(deg as Float) as Float {
return (deg * Math.PI / 180.0).toFloat();
}
}

52
source/LayoutMetrics.mc Normal file
View file

@ -0,0 +1,52 @@
import Toybox.Graphics;
import Toybox.Lang;
// Resolution-independent layout helpers. All sizes are computed as
// fractions of the drawable context so the app adapts to any round
// Garmin display.
module LayoutMetrics {
// --- Menu ring geometry --------------------------------------------
function centerX(dc as Dc) as Number { return dc.getWidth() / 2; }
function centerY(dc as Dc) as Number { return dc.getHeight() / 2; }
// Radius on which the 10 icons sit.
function ringRadius(dc as Dc) as Number {
var min = (dc.getWidth() < dc.getHeight()) ? dc.getWidth() : dc.getHeight();
return (min * 0.38).toNumber();
}
function iconSize(dc as Dc) as Number {
var min = (dc.getWidth() < dc.getHeight()) ? dc.getWidth() : dc.getHeight();
return (min * 0.14).toNumber();
}
function selectedIconSize(dc as Dc) as Number {
return (iconSize(dc) * 1.3).toNumber();
}
// --- History view sections -----------------------------------------
function topSectionHeight(dc as Dc) as Number {
return (dc.getHeight() * 0.15).toNumber();
}
function midSectionHeight(dc as Dc) as Number {
return (dc.getHeight() * 0.70).toNumber();
}
function bottomSectionY(dc as Dc) as Number {
return (dc.getHeight() * 0.85).toNumber();
}
// --- Loading / delete arc radius ------------------------------------
function edgeArcRadius(dc as Dc) as Number {
var min = (dc.getWidth() < dc.getHeight()) ? dc.getWidth() : dc.getHeight();
return (min * 0.48).toNumber();
}
function edgeArcPenWidth(dc as Dc) as Number {
var min = (dc.getWidth() < dc.getHeight()) ? dc.getWidth() : dc.getHeight();
var w = (min * 0.025).toNumber();
return (w < 3) ? 3 : w;
}
}

49
source/Logger.mc Normal file
View file

@ -0,0 +1,49 @@
import Toybox.Application;
import Toybox.Lang;
import Toybox.Time;
// Lightweight crash / diagnostic logger. Entries live in
// Application.Storage and are pruned with the same retention window
// as events so the watch storage never fills up.
module Logger {
const KEY = "logs";
const MAX_ENTRIES = 50;
function log(msg as String) as Void {
var raw = Application.Storage.getValue(KEY);
var arr = (raw instanceof Array) ? raw : [];
arr.add({
"ts" => Time.now().value(),
"msg" => msg
});
if (arr.size() > MAX_ENTRIES) {
arr = arr.slice(arr.size() - MAX_ENTRIES, arr.size());
}
Application.Storage.setValue(KEY, arr);
}
function getAll() as Array<Dictionary> {
var raw = Application.Storage.getValue(KEY);
return (raw instanceof Array) ? raw : [];
}
function pruneOld() as Void {
var raw = Application.Storage.getValue(KEY);
if (!(raw instanceof Array) || raw.size() == 0) {
return;
}
var cutoff = Time.now().value() - Config.RETENTION_SEC;
var kept = [];
for (var i = 0; i < raw.size(); i++) {
var d = raw[i] as Dictionary;
var ts = d["ts"];
if (ts != null && (ts as Number) >= cutoff) {
kept.add(d);
}
}
if (kept.size() != raw.size()) {
Application.Storage.setValue(KEY, kept);
}
}
}