- Dart 66.7%
- TypeScript 24.8%
- Python 7.4%
- HTML 1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Das Gesammelte ist für die Schweizerische Stiftung Pro Mente Sana gedacht. Ein Tippen auf die Zahl im Kopf führt zum Dashboard: was alle zusammen und was dieses Gerät beigetragen hat, über alle Tage. Überwiesen wird von Hand und freiwillig -- die App hat kein Konto, keine Zahlungsfunktion und keinen Anspruch auf das Geld. Dafür rechnet /api/totals mit ?userId= zusätzlich den eigenen Anteil aus. Der Kurs bekommt einen Anker statt einer Behauptung: Bewegungsmangel kostet die Schweiz rund 1,7 Milliarden Franken im Jahr, etwa 190 pro Kopf (BAG, 2022). Fünf Rappen je hundert Schritte sind daraus gerundet, nicht ausgerechnet -- und genau so steht es auch im Dashboard. Store- und App-Icon zeigten bisher verschiedene Kompositionen: der Store das unbeschnittene Bild, der Launcher das auf die Mitte begrenzte. Jetzt ist es eine einzige, mit Rand ringsum. Strichstärke und Punktgrösse hängen am Rasterabstand statt an der Bildgrösse, sonst verklumpt die Linie, sobald das Raster feiner wird. Der Server hat keine Website mehr: alles ausserhalb von /api antwortet mit "not found". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
| apps | ||
| .gitignore | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
mentesana
Ein Schritt ist ein Punkt. Hundert Schritte sind fünf Rappen.
Die App legt ein unendliches Punktfeld über den Tag. Jeder gegangene Schritt setzt einen Punkt und verbindet ihn mit dem vorherigen. In welche der acht Richtungen es weitergeht, sagt der Kompass; ob die Linie dabei nach links oder rechts abbiegt, sagt die Seitwärtsbewegung des Geräts. Kein GPS, keine Karte, keine Route — nur Richtung und Takt. Um Mitternacht wird aus allen Pfaden des Tages ein Bild.
Das Gesammelte ist für die Schweizerische Stiftung Pro Mente Sana gedacht. Das Dashboard zeigt, was alle zusammen und was das eigene Gerät beigetragen hat; überwiesen wird von Hand und freiwillig. Die App hat kein Konto, keine Zahlungsfunktion und keinen Anspruch auf das Geld — sie zählt, und der Rest ist eine Entscheidung.
Der Kurs ist an eine Zahl angelehnt: Bewegungsmangel kostet die Schweiz rund 1,7 Milliarden Franken im Jahr, etwa 190 pro Kopf (BAG, 2022). Fünf Rappen je hundert Schritte sind daraus gerundet, nicht ausgerechnet.
Live: https://mentesana-api-production.up.railway.app
Quelltext: https://git.urania.news/urania/mentesana
Installieren: F-Droid-Quelle https://urania.news/fdroid/repo hinzufügen
(Fingerabdruck 621a07c8a25899e00f97e4dc07ae7cfb804503be6195a8e0401443f4774a1992)
Aufbau
mentesana/
├── apps/api/ Node + TypeScript. JSON-Datei als Datenbank. Läuft auf Railway.
└── apps/app/ Flutter. Die Android-App mit Schrittzähler und Punktfeld.
Der Server hält keine Sitzung und kennt keine Konten: eine Kennung, ein Datum, ein Pfad. Die App schickt jedes Mal ihren ganzen Tagespfad, der Server prüft, ob er den gespeicherten fortsetzt, und übernimmt ihn dann. Ein verlorenes Paket kostet deshalb nichts weiter als einen zweiten Versuch.
apps/api
Eine Datei, ein Objekt im Speicher, atomare Schreibvorgänge. Auf Railway liegt sie
auf einem Volume unter /data/mentesana.json und lässt sich jederzeit mit cat
anschauen — genau das war der Punkt an einer JSON-Datenbank.
Die Galerie lebt in der App. Es gibt bewusst keine Website dazu: eine zweite, halb
gepflegte Ansicht derselben Bilder im Browser bräuchte niemand. Alles ausserhalb
von /api antwortet mit not found.
| Route | Zweck |
|---|---|
GET /api/health |
Lebenszeichen |
GET /api/totals?userId=… |
Schritte und Rappen — gesamt, heute, und mit userId der eigene Anteil |
POST /api/sync |
Tagespfad hochladen (Schlüssel nötig) |
GET /api/today |
Der laufende Tag, alle Geher |
GET /api/gallery?limit=30 |
Abgeschlossene Tage |
GET /api/artwork/:date |
Ein Tag |
Kunstwerke tragen keine Namen. Ein Weg heisst nach dem Tag, an dem er gegangen wurde; alles andere wäre eine Deutung, die niemand bestellt hat.
POST /api/sync ist streng, aus zwei Gründen.
Der Pfad ist ein Gang über ein Raster, also muss jeder Punkt ein Nachbar des vorherigen sein. Was diese Regel bricht, ist kaputt oder gefälscht.
Und ein gegangener Weg lässt sich nicht nachträglich ändern. Der Server nimmt nur
an, was den gespeicherten Pfad fortsetzt: abgeschlossene Segmente müssen Punkt für
Punkt gleich bleiben, das letzte darf wachsen, hinten dürfen neue dazukommen. Alles
andere ist ein Umschreiben und wird mit 409 samt dem gespeicherten Stand
abgewiesen. Das Gerät übernimmt dann diesen Stand und geht von dessen letztem Punkt
aus weiter — nach einer Neuinstallation mit derselben Kennung etwa.
Gerechnet wird in Milli-Rappen (50 pro Schritt), gerundet erst auf der Summe. Bei Bruchzahlen würde sich über hunderttausende Schritte ein Fehler aufsummieren, den am Ende jemand bezahlen müsste.
Schlüssel
Schreiben braucht einen Schlüssel, Lesen nicht — die Galerie ist öffentlich. Der
Server erwartet ihn als Authorization: Bearer … oder X-Mentesana-Key und
vergleicht ihn in konstanter Zeit mit MENTESANA_KEY aus der Umgebung. Ist die
Variable nicht gesetzt, weist er jeden Schreibzugriff ab: ein Server, der bei
fehlender Konfiguration alles durchlässt, ist gefährlicher als einer, der steht.
Der Schlüssel steckt in der App und in einer Railway-Variable. Er hält fremde Schreiber ab, die die Adresse zufällig finden — mehr nicht: wer die APK entpackt, hat ihn. Gegen gefälschte Pfade schützt nicht er, sondern die Regel, dass ein gespeicherter Pfad sich nicht mehr ändern lässt.
npm install
npm test # 24 Tests
npm run dev # http://localhost:3000
DATA_DIR bestimmt, wo die Datenbank liegt (Vorgabe: ./data).
PURGE_USER_IDS=a,b räumt beim Start die Pfade dieser Kennungen aus jedem Tag und
jedem Kunstwerk. Für Testdaten, die es in die echte Datenbank geschafft haben —
danach die Variable wieder leeren. Es gibt bewusst keine Route dafür: Löschen ist
Wartung und gehört an die Umgebung des Servers, nicht ans offene Netz.
apps/app
cd apps/app
flutter test
flutter run --dart-define=MENTESANA_KEY=…
Ohne MENTESANA_KEY läuft die App, aber der Server nimmt nichts von ihr an; sie
sagt das beim Start. Gegen einen lokalen Server:
--dart-define=MENTESANA_API=http://10.0.2.2:3000 (das ist der Rechner, von dem
aus der Emulator gestartet wurde).
Es gibt nichts einzustellen und kein Konto: keine Serveradresse, keinen Namen, keine Anmeldung. Ein Gerät bekommt beim ersten Start eine Kennung, und die ist alles, was einen Weg seinem Geher zuordnet. Wer die App öffnet, soll gehen können, nicht konfigurieren.
Veröffentlichen
flutter build apk --release \
--dart-define=MENTESANA_API=https://mentesana-api-production.up.railway.app \
--dart-define=MENTESANA_KEY="$MENTESANA_KEY"
Signiert wird mit dem Schlüsselbund, auf den android/key.properties zeigt. Die
Datei steht nicht im Repo, und der Schlüsselbund gehört gesichert: ihn zu
verlieren heisst, die App nie wieder aktualisieren zu können, ihn zu
veröffentlichen heisst, dass jeder Aktualisierungen unterschieben kann. Fehlt die
Datei, greift der Debug-Schlüssel — gut genug zum Ausprobieren, nichts zum
Veröffentlichen.
Vor dem Veröffentlichen prüfen, ob der Schlüssel wirklich im Build steckt. Einmal ist er stillschweigend nicht angekommen, und die veröffentlichte Version konnte nichts senden:
unzip -p build/app/outputs/flutter-apk/app-release.apk lib/arm64-v8a/libapp.so \
| grep -qaF -e "$MENTESANA_KEY" && echo "Schlüssel drin"
Ins F-Droid-Repo kommt die fertige APK über die Publish-API von
/home/ubuntu/Code/urania:
curl -X POST "https://urania.news/api/publish?name=mentesana-1.0.4.apk" \
-H "Authorization: Bearer $PUBLISH_TOKEN" \
--data-binary @build/app/outputs/flutter-apk/app-release.apk
Wie gezeichnet wird
lib/src/walk/geometry.dart hält die Regel: der Kompass wählt einen von acht
Sektoren, die Seitwärtsbewegung dreht die Richtung um höchstens 20° dazu. Das
reicht, um nahe an einer Sektorgrenze umzukippen, und reicht nicht, um den Kompass
zu überstimmen. Wer geradeaus geht, zeichnet eine Gerade; wer immer wieder nach
rechts ausweicht, zeichnet eine Kurve.
Eine Pause — Dienst gestoppt, Gerät neu gestartet, Mitternacht — beendet das aktuelle Segment und beginnt ein neues am selben Punkt. Im Bild entsteht so eine ehrliche Lücke statt einer erfundenen Verbindung. Von Hand trennen lässt sich die Linie nicht: die Lücken sollen die echten Unterbrechungen sein, keine gestalterischen. Ohne Kompasssignal wird gar nicht gezeichnet.
Alle Linien sind grün. Der eigene Weg leuchtet, die der anderen liegen blass darunter: das Bild eines Tages ist ein gemeinsames, kein Wettbewerb um die auffälligste Spur.
Warum zwei Schrittzähler
Der Zähler des Betriebssystems ist genau, aber langsam — Android bündelt seine Meldungen und liefert sie mit mehreren Sekunden Verzug. Zum Zählen reicht das, zum Zusehen nicht.
Deshalb erkennt StepDetector den Schritt sofort aus der Beschleunigung und
zeichnet ihn, und StepReconciler hält beide Zahlen zusammen: meldet die Hardware
mehr Schritte als gezeichnet wurden, werden sie nachgetragen; meldet sie weniger,
wird ausgesetzt, bis die Rechnung wieder aufgeht. Ein gezeichneter Punkt lässt sich
nicht zurücknehmen, also ist Aussetzen die einzige Korrektur nach unten. Über die
Zeit stimmt das Bild damit genau mit dem Betriebssystem überein — und niemand
spendet dafür, dass er sein Telefon geschüttelt hat.
Bilder
tool/make_icons.py erzeugt das App-Icon, das Kopfbild fürs Repo und das
Sternbild der Quelle — alles aus demselben Algorithmus, den auch die App
zeichnet, nur mit festem Zufallsgenerator. Das Icon liegt als klassisches und
als adaptives Icon vor: ab Android 8 legt der Launcher seine eigene Form
darüber, deshalb darf das Feld bis an den Rand laufen und beschnitten werden,
während die Linie in der Mitte bleibt.
tool/screenshots_test.dart rendert die Bildschirmfotos fürs Repo, ohne
Emulator: Flutters Golden-Mechanismus zeichnet dieselben Widgets, die auf dem
Gerät laufen, in eine PNG-Datei.
python3 tool/make_icons.py
flutter test tool/screenshots_test.dart --update-goldens
Beides landet unter tool/art/ und tool/screenshots/ und wird von Hand ins
Repo geschoben — die Publish-API nimmt neben APKs auch Metadaten und Grafiken:
curl -X POST "https://urania.news/api/metadata?package=news.urania.mentesana" \
-H "Authorization: Bearer $PUBLISH_TOKEN" --data-binary @metadata.yml
curl -X POST "https://urania.news/api/graphic?package=news.urania.mentesana&kind=icon" \
-H "Authorization: Bearer $PUBLISH_TOKEN" --data-binary @tool/art/icon-512.png
Hintergrundbetrieb
Massgeblich gezählt wird mit dem Schrittzähler des Betriebssystems
(ACTIVITY_RECOGNITION). Damit die App weiterzeichnet,
wenn der Bildschirm aus ist, läuft die gesamte Logik in einem Vordergrunddienst
(flutter_foreground_task, foregroundServiceType="health") in einem eigenen
Isolate. Android darf die Oberfläche jederzeit abräumen, den Dienst nicht, solange
seine Benachrichtigung steht. Die Oberfläche ist damit ein Fenster auf diesen
Zustand und nicht seine Quelle: sie bekommt die neuen Punkte über den Port
zugeschickt und holt sich den ganzen Pfad neu, sobald die Schrittzahlen
auseinanderlaufen.
Wird der Dienst doch einmal beendet, geht nichts verloren: der Schrittzähler des
Geräts zählt seit dem letzten Neustart weiter, und beim nächsten Start trägt
CumulativeStepCounter nach, was in der Zwischenzeit gegangen wurde.
Beim ersten Start fragt die App nach Bewegungsdaten, Benachrichtigungen und einer Ausnahme von der Akku-Optimierung. Ohne die letzte legt Android den Dienst nach einiger Zeit schlafen.
Was nicht geht: iOS erlaubt keinen dauerhaften Hintergrunddienst dieser Art.
Dort zählt zwar CMPedometer im Hintergrund weiter und wird beim Öffnen der App
nachgetragen, aber ohne Kompasswerte aus der Zwischenzeit landen diese Schritte
als gerade Linie im Bild. Die App ist auf Android gebaut.
Railway
Das Projekt heisst mentesana, der Service mentesana-api, das Volume hängt unter
/data. Deployt wird aus apps/api/ heraus:
cd apps/api && railway up --service mentesana-api
Nixpacks führt beim Bauen npm run build und npm run test aus — ein Deploy, das
die eigene Prüfung nicht besteht, startet gar nicht erst.
Railway kündigt railway.json zum 1. Dezember 2026 ab; wer vorher umstellt, führt
railway config migrate --apply aus und benennt den Service in der erzeugten
.railway/railway.ts von api auf mentesana-api um.