From b1411625aabea37475b7a2da62196acb46171542 Mon Sep 17 00:00:00 2001
From: Tobi G
Date: Mon, 31 Aug 2026 14:24:52 +0200
Subject: [PATCH] Teilen-Blatt, HIG-Blaetter, geteilte Ansichtseinstellungen
(2026.8.31.1)
Share-Erweiterung: ein PDF aus Mail landet ueber Teilen -> DataMetric360 als
Tankbeleg. Alles Native liegt versioniert unter companion-app/native/, weil
ios/ gitignored ist und npx cap add ios es sonst wieder frisst;
scripts/ios-teilen-einrichten.mjs haengt es bei jedem Bau ins Xcode-Projekt
und ist in ios-signieren.sh eingehaengt. Der pbxproj-Teil ist auf keinem Mac
erprobt - er bricht vor dem Schreiben ab, und docs/SHARE_EXTENSION.md
beschreibt dieselben Handgriffe fuer Xcode.
"In den Kalender uebernehmen" ging in der App nie: WKWebView ignoriert das
download-Attribut, der Klick lief ins Leere. Auf dem Geraet jetzt
@capacitor/filesystem plus @capacitor/share.
useTheme/useBoden legten je Aufrufer eigenen Zustand an - fuenf Kopien. Der
Schalter aenderte nur seine eigene, data-theme in App.tsx blieb stehen, die
Tag/Nacht-Umschaltung wirkte erst nach einem Neustart. Jetzt ein Speicher je
Einstellung ueber useSyncExternalStore. Zwei Regressionstests, gegen den
alten Stand als fehlschlagend nachgewiesen.
Alle Popups nach Apple HIG: neue Bausteine Sheet und ActionSheet, blickdicht
und mit abgedunkeltem Schleier, in beiden Codebasen. Popup portalierte nach
document.body - ausserhalb von [data-theme] - und war deshalb im Tagmodus
fast unsichtbar; Portal zielt jetzt auf .ads-root.
ImagePlaceholder merkte sich "fehlgeschlagen" ohne die Adresse: nach einem
leeren Galerieplatz zeigten auch die mit Foto nur noch den Platzhalter.
Vier Regressionstests, ebenfalls gegen den alten Stand geprueft.
Bildadressen tragen jetzt einen Cache-Brecher - HA liefert /local/ mit 31
Tagen Cache-Vorgabe aus, im Panel eingefuegte Radfotos erschienen in der App
deshalb nicht.
uebersichtsbild: Panel speichert den Namen, die App verglich gegen den
Dateinamen - der Vergleich traf nie zu. Kanonisch ist der Name.
Weiter: Navigationspfeil statt gleichschenkligem Dreieck auf der Streckenlinie
(die Kerbe unterscheidet Kopf und Ende), CI-Symbol tour-s als Fahrten-Icon,
Datumsfelder zeigen ihren Wert ohne erstes Antippen, feste Beispielnamen im
Setup, Kopf-Gegengewicht fuer mittige Titel, Standort-Blatt mit festem
Fussabstand, NSLocationWhenInUseUsageDescription, watchPosition fuer die
Live-Ortung, Art-Pille wieder als Knopf, Zwischenablage fuer Tankbelege.
Co-Authored-By: Claude Opus 5
---
AGENTS.md | 120 +++++++++
companion-app/docs/SHARE_EXTENSION.md | 120 +++++++++
companion-app/native/App.entitlements | 15 ++
.../teilen/DataMetric360Share.entitlements | 16 ++
companion-app/native/teilen/Info.plist | 53 ++++
.../native/teilen/ShareViewController.swift | 157 +++++++++++
companion-app/package.json | 7 +-
companion-app/scripts/ios-signieren.sh | 43 +++
.../scripts/ios-teilen-einrichten.mjs | 198 ++++++++++++++
companion-app/src/App.tsx | 32 +++
companion-app/src/Shell.tsx | 16 +-
.../src/daten/geteilterBeleg.test.ts | 95 +++++++
companion-app/src/daten/geteilterBeleg.ts | 122 +++++++++
companion-app/src/daten/profilAdapter.ts | 3 +-
companion-app/src/daten/zwischenablage.ts | 73 ++++++
companion-app/src/screens/BelegPopup.tsx | 129 +++++++++
companion-app/src/screens/DatensatzPopup.tsx | 13 +-
companion-app/src/screens/Datumsfeld.tsx | 58 ++++
companion-app/src/screens/Einstellungen.tsx | 55 ++--
companion-app/src/screens/FahrtDetail.tsx | 33 ++-
companion-app/src/screens/Fahrzeugdaten.tsx | 2 +-
companion-app/src/screens/HistorienImport.tsx | 14 +-
companion-app/src/screens/Karte.tsx | 88 ++++++-
companion-app/src/screens/Reifen.tsx | 18 +-
companion-app/src/screens/Service.tsx | 17 +-
companion-app/src/screens/Standort.tsx | 40 ++-
companion-app/src/screens/TankDetail.tsx | 24 +-
companion-app/src/screens/Tanken.tsx | 58 +++-
companion-app/src/screens/Uebersicht.tsx | 6 +-
companion-app/src/screens/Versicherung.tsx | 16 +-
companion-app/src/screens/bausteine.tsx | 64 +++--
companion-app/src/screens/bilder.ts | 28 +-
.../src/screens/bildplatzhalter.test.tsx | 69 +++++
companion-app/src/screens/kalender.ts | 48 +++-
companion-app/src/stile/rahmen.css | 12 +
companion-app/src/stile/screens.css | 89 ++++++-
companion-app/src/symbole.tsx | 10 +-
companion-app/src/theme.test.ts | 33 ++-
companion-app/src/theme.test.tsx | 73 ++++++
companion-app/src/theme.ts | 179 ++++++++-----
.../audi_dashboard/einstellungen.py | 30 ++-
.../audi_dashboard/frontend/app/bundle.json | 2 +-
.../audi_dashboard/frontend/app/bundle.zip | Bin 257018 -> 266016 bytes
.../frontend/audi-dashboard-app.js | 248 ++++++++++++------
.../frontend/audi-dashboard-ios.css | 28 +-
.../frontend/audi-dashboard.css | 111 +++++---
.../audi_dashboard/manifest.json | 2 +-
.../ImagePlaceholder/ImagePlaceholder.tsx | 22 +-
design-system/src/components/Pill/Pill.css | 12 +
design-system/src/components/Pill/Pill.tsx | 25 +-
design-system/src/components/Popup/Popup.tsx | 22 +-
.../src/components/Sheet/ActionSheet.tsx | 98 +++++++
design-system/src/components/Sheet/Sheet.css | 214 +++++++++++++++
design-system/src/components/Sheet/Sheet.tsx | 103 ++++++++
design-system/src/components/Sheet/index.ts | 4 +
design-system/src/index.ts | 3 +
design-system/src/styles.css | 1 +
package-lock.json | 43 ++-
58 files changed, 2846 insertions(+), 368 deletions(-)
create mode 100644 companion-app/docs/SHARE_EXTENSION.md
create mode 100644 companion-app/native/App.entitlements
create mode 100644 companion-app/native/teilen/DataMetric360Share.entitlements
create mode 100644 companion-app/native/teilen/Info.plist
create mode 100644 companion-app/native/teilen/ShareViewController.swift
create mode 100644 companion-app/scripts/ios-teilen-einrichten.mjs
create mode 100644 companion-app/src/daten/geteilterBeleg.test.ts
create mode 100644 companion-app/src/daten/geteilterBeleg.ts
create mode 100644 companion-app/src/daten/zwischenablage.ts
create mode 100644 companion-app/src/screens/BelegPopup.tsx
create mode 100644 companion-app/src/screens/Datumsfeld.tsx
create mode 100644 companion-app/src/screens/bildplatzhalter.test.tsx
create mode 100644 companion-app/src/theme.test.tsx
create mode 100644 design-system/src/components/Sheet/ActionSheet.tsx
create mode 100644 design-system/src/components/Sheet/Sheet.css
create mode 100644 design-system/src/components/Sheet/Sheet.tsx
create mode 100644 design-system/src/components/Sheet/index.ts
diff --git a/AGENTS.md b/AGENTS.md
index 5fbfd5a..2f6d893 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6068,3 +6068,123 @@ rendering bug in a view.
`custom_components/audi_dashboard/` directly. Do not reintroduce a copy.
- Security principle: HA is never publicly exposed; only narrowly scoped surfaces (MQTT broker,
proxy allowlist) may be exposed, each by explicit decision.
+
+---
+
+## AR. Teilen-Blatt, Kalender und der Rest des Owner-Stapels vom 2026-08-31
+
+Alles unter `companion-app/`, sofern nicht anders vermerkt; Panel-Anteile nach der Paritätsregel.
+
+### Share-Erweiterung: alles versioniert, damit `cap add ios` sie nicht wieder frisst
+
+Ein PDF aus Mail über **Teilen → DataMetric360** landet als Tankbeleg in der App. Der Weg im
+Ganzen steht in `companion-app/docs/SHARE_EXTENSION.md`; das Wesentliche hier:
+
+- Eine Erweiterung ist ein eigener Prozess. Der einzige gemeinsame Boden mit der App ist eine
+ **App-Gruppe** (`group.app.datametric360`). Das PDF reist Base64-kodiert durch die geteilten
+ `UserDefaults`, weil `@capacitor/preferences` genau die liest, sobald man ihm die Gruppe nennt —
+ eine Datei im Gruppen-Container bräuchte eigenen nativen Code auf der App-Seite, also ein
+ zusätzliches Plugin für einen Weg, den es sonst nicht gäbe. Preis: 4-MB-Grenze, sauber gemeldet.
+- **`companion-app/ios/` ist gitignored.** Alles Native liegt deshalb unter
+ `companion-app/native/` und wird von `scripts/ios-teilen-einrichten.mjs` bei jedem Bau wieder
+ eingespielt — dieselbe Regel, die schon die Team-Kennung und den Standort-Schlüssel in
+ `ios-signieren.sh` getrieben hat. Das Skript ist idempotent und in `ios-signieren.sh`
+ eingehängt.
+- **Der pbxproj-Teil ist auf keinem Mac erprobt** (geschrieben ohne Zugriff auf macOS). Er bricht
+ vor dem Schreiben ab, wenn etwas nicht passt, und `docs/SHARE_EXTENSION.md` beschreibt dieselben
+ Handgriffe für Xcode. Nicht als „fertig verifiziert" behandeln, bevor es auf dem Mac gelaufen ist.
+- Einmalig im Apple-Portal nötig: App-Gruppe anlegen, `app.datametric360.teilen` anlegen, beide
+ App-IDs der Gruppe zuordnen. Ohne das schlägt das Signieren fehl.
+- Die App-Seite ist geprüft (6 Tests in `src/daten/geteilterBeleg.test.ts`): Eintrag → `File`,
+ genau einmal abgeholt, liegengebliebene und unlesbare Einträge verworfen.
+
+### „In den Kalender übernehmen" ging in der App nie
+
+`` mit Blob-Adresse — **WKWebView ignoriert `download`**, der Klick lief ins Leere.
+Auf dem Gerät jetzt `@capacitor/filesystem` (Cache) plus `@capacitor/share`; im Browser und im
+Panel bleibt der Download. Betrifft Reifen-Wechseltermin und Service-Werkstatttermin.
+
+### Ein Wert, viele Kopien: `useTheme`/`useBoden` waren pro Aufrufer eigener Zustand
+
+`useTheme()` legte bei jedem Aufruf ein eigenes `useState` an — fünf unabhängige Kopien
+(`App.tsx`, `Einstellungen.tsx`, `Typenschild.tsx` ×2). Der Schalter änderte nur seine eigene;
+`data-theme` in `App.tsx` blieb stehen, die Umschaltung wirkte erst nach einem Neustart. Der
+Boden-Schalter hatte denselben Fehler. Jetzt `useSyncExternalStore` über einen Speicher je
+Einstellung in `theme.ts` — Reacts eigener Weg für Zustand außerhalb des Komponentenbaums, ohne
+Kontext-Anbieter und ohne geänderte Aufrufstellen. Zwei Regressionstests, gegen den alten Stand
+als fehlschlagend nachgewiesen.
+
+**Regel:** eine Einstellung, die mehr als eine Komponente liest, gehört nicht in `useState` je
+Aufrufer.
+
+### `uebersichtsbild`: Panel und App speicherten Verschiedenes ins selbe Feld
+
+Panel schreibt den **Namen** („Seitenansicht"), die App verglich gegen den **Dateinamen**. Der
+Vergleich konnte nie zutreffen — die App zeigte immer den ersten Platz. Kanonisch ist der Name
+(die ältere, bereits gespeicherte Form; dieselbe Entscheidung wie beim Wartungsplan, Abschnitt AE).
+Korrigiert in `Einstellungen.tsx`, `Uebersicht.tsx` und `profilAdapter.ts`.
+
+### Popups nach Apple HIG
+
+Neu im Design-System: `Sheet` (Formularblatt) und `ActionSheet` (Auswahlblatt, „Abbrechen"
+abgesetzt in eigener Karte). Beide blickdicht, mit abgedunkeltem Schleier, sichere Zone unten frei,
+Escape schließt. Umgestellt: SmartDeal, Tankbeleg, Bildmenü, Datensatz, Historien-Import — und im
+Panel `.bildmenu`, `.sdpopup`, `.beleg-popup` samt Schleier. Das Setup-Fenster bleibt: es füllt
+ohnehin die volle Höhe und ist damit bereits die HIG-Form „Vollbild".
+
+`menueLage` („ansicht"/„rad") ist entfallen — ein Aktionsblatt kommt immer von unten.
+
+### `Popup` portalierte nach `document.body` — außerhalb des Themes
+
+Die Farbwerte hängen an `[data-theme]` auf `.ads-root`. Alles, was nach `document.body` portaliert
+wird, erbt deshalb **immer** den Nachtmodus: im Tagmodus war jedes Fenster eine zu 5 % deckende
+weiße Fläche auf weißem Grund. Betraf alle vier bestehenden Dialoge. `Popup`/`Sheet`/`ActionSheet`
+zielen jetzt auf `.ads-root`.
+
+**Regel:** kein Portal nach `document.body`. `position: fixed` bleibt davon unberührt — `.ads-root`
+setzt kein `transform`.
+
+### `ImagePlaceholder` merkte sich „fehlgeschlagen" ohne die Adresse
+
+In der Galerie unter „Mein Audi" bleibt dieselbe Komponente stehen und bekommt nur eine neue
+`src`. Ein einmal gesetztes `true` galt damit für alle folgenden Bilder — nach einem leeren Platz
+zeigten auch die mit Foto nur noch den Platzhalter. Jetzt wird die Adresse gemerkt. Das Panel hatte
+die Falle nie: `bildWeiter()` nimmt `bild-fehlt` ausdrücklich wieder weg. Vier Regressionstests,
+gegen den alten Stand als fehlschlagend nachgewiesen.
+
+### Bildadressen ohne Cache-Brecher
+
+HA liefert `/local/` mit `Cache-Control: public, max-age=2678400` — **31 Tage** (nachgemessen).
+Das Panel hängt seit jeher `?v=` an, die App nicht: ein im Panel eingefügtes Radfoto erschien in
+der App nicht, ein ersetztes blieb das alte, ein 404 blieb ein 404. `bildUrl()` trägt jetzt einen
+Bildstand, erneuert bei Start und nach jedem Hochladen/Löschen.
+
+### Datumsfelder zeigten ihren Wert erst nach dem ersten Antippen
+
+Alle sieben hängen an Werten, die asynchron eintreffen; WKWebView zieht den inneren Aufbau eines
+` ` bei einer späteren Wertänderung nicht zuverlässig nach. Neue gemeinsame
+Komponente `Datumsfeld`, deren Schlüssel genau einmal wechselt (leer → gefüllt). Bewusst nicht der
+Wert selbst als Schlüssel: dann entstünde das Feld bei jeder Auswahl neu und die Datumsauswahl
+schlösse mitten in der Bedienung. **Auf dem Gerät zu bestätigen** — WebKit-spezifisch, im Chromium
+der Vorschau nicht nachstellbar.
+
+### Setup: „(z. B. …)" bleibt stehen
+
+Der Hinweis kam aus der gerade zugeordneten Entität und änderte sich mit jeder Auswahl mit.
+Vorgeschichte: bis 2026-08-28 stand dort ein ausgedachtes Beispiel, das für zwei Felder falsch war.
+Beide Beanstandungen zusammen: **fest, aber nicht ausgedacht** — die 22 Beispiele stehen jetzt als
+`beispiel` im `FELDER`-Katalog und sind die abgelesenen Namen der laufenden Integration.
+`entitaetIdKurz()` und zwei Helfer (rund 60 Zeilen) haben keinen Aufrufer mehr und sind entfallen.
+
+### Weiteres aus demselben Stapel
+
+Fahrtrichtung als Dreiecke statt Punkte auf der Streckenlinie (Abstand nach Strecke, nicht nach
+Stützpunkten — die Punktdichte hängt an der Geschwindigkeit); Art-Pille auf der Einzelfahrt ist
+wieder ein Knopf (im Panel seit jeher, in der App ein totes ``); Kopf-Gegengewicht, damit der
+Titel auf Unterseiten wirklich mittig steht (gemessen: 17 px zu weit rechts, beide Codebasen);
+Standort-Blatt ohne `min-height`, 34 px Fußabstand (18 war zu wenig, 76–89 zu viel); Nebel über dem
+Fahrzeugfoto von 42 %/78 % auf 26 %/96 %; FIN statt „Fahrgestellnummer"; Solldruckfelder auf 58 px
+(die Klasse stand im Markup, hatte aber keine Regel); Markenlogos einheitlich auf Zeilenhöhe 16 px;
+`watchPosition` statt `getCurrentPosition` für die Live-Ortung, mit Abmeldung beim Verlassen der
+Seite; `NSLocationWhenInUseUsageDescription` in `ios-signieren.sh` (in der ausgelieferten `.ipa`
+fehlte jeder Location-Schlüssel — deshalb fragte iOS nie).
diff --git a/companion-app/docs/SHARE_EXTENSION.md b/companion-app/docs/SHARE_EXTENSION.md
new file mode 100644
index 0000000..095fe1b
--- /dev/null
+++ b/companion-app/docs/SHARE_EXTENSION.md
@@ -0,0 +1,120 @@
+# Tankbeleg aus dem Teilen-Blatt
+
+Ein PDF aus Mail (oder einer anderen App) über **Teilen → DataMetric360** direkt
+als Tankbeleg übernehmen.
+
+## Wie es zusammenhängt
+
+```
+Mail ──Teilen──► DataMetric360Share (eigener Prozess)
+ │
+ │ PDF → Base64 → UserDefaults der App-Gruppe
+ │ group.app.datametric360
+ ▼
+ datametric360://beleg (öffnet die App)
+ │
+ ▼
+ App.tsx ──holt ab──► Tanken-Seite ──► belegLesen()
+```
+
+Eine Erweiterung ist ein eigener Prozess mit eigenem Dateibereich; der einzige
+gemeinsame Boden mit der App ist die **App-Gruppe**. Der Beleg reist deshalb
+Base64-kodiert durch die geteilten `UserDefaults` — die liest
+`@capacitor/preferences` ohnehin schon, sobald man ihm die Gruppe nennt. Eine
+Datei im Gruppen-Container wäre der sauberere Ort, bräuchte auf der App-Seite
+aber eigenen nativen Code, also ein zusätzliches Plugin für einen Weg, den es
+sonst nicht gäbe.
+
+Der Preis ist eine Größengrenze von 4 MB (`grenzeBytes` im
+`ShareViewController`). Ein Tankbeleg liegt bei einigen Dutzend Kilobyte; ein
+gescanntes Mehrseiten-PDF kann darüber liegen — dann sagt die Erweiterung das,
+und der Weg über **Beleg hochladen → Datei auswählen** in der App bleibt.
+
+## Wo was liegt
+
+| Datei | Rolle |
+|---|---|
+| `native/teilen/ShareViewController.swift` | nimmt das PDF entgegen, legt es ab, öffnet die App |
+| `native/teilen/Info.plist` | wann DataMetric360 im Teilen-Blatt erscheint (genau ein PDF) |
+| `native/teilen/DataMetric360Share.entitlements` | App-Gruppe für die Erweiterung |
+| `native/App.entitlements` | dieselbe App-Gruppe für die App |
+| `scripts/ios-teilen-einrichten.mjs` | hängt das alles ins erzeugte Xcode-Projekt |
+| `src/daten/geteilterBeleg.ts` | holt den Beleg ab, räumt ihn weg |
+| `src/App.tsx` | prüft beim Start und bei jeder Rückkehr, springt auf Tanken |
+| `src/screens/Tanken.tsx` | nimmt ihn auf und lädt ihn hoch |
+
+**`companion-app/ios/` ist gitignored** und wird von `npx cap add ios` neu
+gebaut. Alles Native muss deshalb unter `native/` liegen und bei jedem Bau
+eingespielt werden — dieselbe Regel, die schon die Team-Kennung und den
+Standort-Schlüssel in `ios-signieren.sh` getrieben hat.
+
+## Einmalig im Apple-Entwicklerportal
+
+Ohne diese drei Schritte schlägt das Signieren fehl (die App-Gruppe muss
+existieren, bevor ein Profil sie enthalten kann):
+
+1. **Identifiers → App Groups**: `group.app.datametric360` anlegen.
+2. **Identifiers → App IDs**: `app.datametric360.teilen` anlegen, Capability
+ *App Groups* aktivieren, die Gruppe zuordnen.
+3. Bei der bestehenden App-ID `app.datametric360` ebenfalls *App Groups*
+ aktivieren und dieselbe Gruppe zuordnen.
+
+Danach in Xcode einmal die Profile neu ziehen lassen — oder schlicht
+`ios-signieren.sh` laufen lassen, das macht `-allowProvisioningUpdates`
+selbst. **Merkposten aus Abschnitt AI:** ein zwischengespeichertes altes
+Profil wird stillschweigend weiterverwendet. Ändert sich etwas an den
+Berechtigungen, das passende `.mobileprovision` unter
+`~/Library/Developer/Xcode/UserData/Provisioning Profiles` löschen und neu
+exportieren.
+
+## Bauen
+
+```bash
+bash scripts/ios-signieren.sh
+```
+
+Der Schritt „Share-Erweiterung einrichten" läuft dort automatisch, vor dem
+Archivieren.
+
+## Wenn das Skript abbricht
+
+`ios-teilen-einrichten.mjs` bearbeitet die `project.pbxproj` über das
+npm-Paket `xcode`. **Das ist auf keinem Mac erprobt** — geschrieben ohne
+Zugriff auf macOS. Bricht es ab, ist nichts halb eingerichtet (es bricht vor
+dem Schreiben ab), und dasselbe lässt sich in Xcode von Hand erledigen:
+
+1. **File → New → Target… → Share Extension**, Name `DataMetric360Share`,
+ Sprache Swift, „Activate scheme" verneinen.
+2. Die von Xcode erzeugten `ShareViewController.swift`, `Info.plist` und das
+ `MainInterface.storyboard` **löschen** und stattdessen die Dateien aus
+ `native/teilen/` in das Ziel legen (Storyboard wird nicht gebraucht, die
+ Erweiterung hat keine eigene Oberfläche).
+3. Bundle-ID des Ziels auf `app.datametric360.teilen` setzen.
+4. Beide Ziele → **Signing & Capabilities → + Capability → App Groups**,
+ `group.app.datametric360` anhaken.
+5. Ziel `DataMetric360Share` → Build Settings → *Code Signing Entitlements* auf
+ `DataMetric360Share/DataMetric360Share.entitlements`; Ziel `App` auf
+ `App/App.entitlements`.
+6. Ziel `App` → Build Phases → **Embed App Extensions** muss
+ `DataMetric360Share.appex` enthalten.
+7. `ios/App/App/Info.plist`: URL-Schema `datametric360` unter
+ `CFBundleURLTypes` eintragen.
+
+Schritt 7 ist der leiseste: fehlt er, wird der Beleg abgelegt, die App aber
+nicht geöffnet — er taucht dann erst auf, wenn man die App das nächste Mal von
+Hand startet.
+
+## Prüfen, ob es wirkt
+
+1. In Mail ein PDF antippen → **Teilen** → DataMetric360 muss in der Liste
+ stehen (nur bei genau einem PDF, sonst absichtlich nicht).
+2. Antippen → die App kommt nach vorn, springt auf **Tanken**, das Formular ist
+ offen und liest den Beleg („Beleg wird gelesen …").
+3. Danach ist der Eintrag weg: dieselbe Datei ein zweites Mal zu teilen muss
+ wieder funktionieren, ein bloßes Wechseln in die App darf den Beleg **nicht**
+ erneut verarbeiten.
+
+## Was hier nicht steht
+
+Android. Der Ablauf ist dort ein anderer (`intent-filter` statt App-Gruppe),
+und die Android-Hülle dieses Projekts ist ohnehin nicht in Gebrauch.
diff --git a/companion-app/native/App.entitlements b/companion-app/native/App.entitlements
new file mode 100644
index 0000000..d5e5b5a
--- /dev/null
+++ b/companion-app/native/App.entitlements
@@ -0,0 +1,15 @@
+
+
+
+
+
+ com.apple.security.application-groups
+
+ group.app.datametric360
+
+
+
diff --git a/companion-app/native/teilen/DataMetric360Share.entitlements b/companion-app/native/teilen/DataMetric360Share.entitlements
new file mode 100644
index 0000000..0ae9324
--- /dev/null
+++ b/companion-app/native/teilen/DataMetric360Share.entitlements
@@ -0,0 +1,16 @@
+
+
+
+
+
+ com.apple.security.application-groups
+
+ group.app.datametric360
+
+
+
diff --git a/companion-app/native/teilen/Info.plist b/companion-app/native/teilen/Info.plist
new file mode 100644
index 0000000..8ea1174
--- /dev/null
+++ b/companion-app/native/teilen/Info.plist
@@ -0,0 +1,53 @@
+
+
+
+
+
+ CFBundleDevelopmentRegion
+ de
+ CFBundleDisplayName
+ DataMetric360
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ NSExtension
+
+ NSExtensionAttributes
+
+ NSExtensionActivationRule
+
+ NSExtensionActivationSupportsFileWithMaxCount
+ 1
+ NSExtensionActivationSupportsAttachmentsWithMaxCount
+ 1
+
+
+ NSExtensionPointIdentifier
+ com.apple.share-services
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).ShareViewController
+
+
+
diff --git a/companion-app/native/teilen/ShareViewController.swift b/companion-app/native/teilen/ShareViewController.swift
new file mode 100644
index 0000000..5e94a51
--- /dev/null
+++ b/companion-app/native/teilen/ShareViewController.swift
@@ -0,0 +1,157 @@
+//
+// ShareViewController.swift
+// DataMetric360Share
+//
+// Nimmt ein PDF aus dem iOS-Teilen-Blatt entgegen (typischerweise ein
+// Tankbeleg aus Mail) und reicht es an die App weiter.
+//
+// WIE DIE ÜBERGABE FUNKTIONIERT — UND WARUM SO
+// --------------------------------------------
+// Eine Share-Erweiterung ist ein eigener Prozess mit eigenem Dateibereich.
+// Sie kann der App nichts direkt geben; der einzige gemeinsame Boden ist
+// eine App-Gruppe. Der Weg hier:
+//
+// 1. Das PDF wird gelesen und Base64-kodiert.
+// 2. Es landet zusammen mit seinem Namen in den geteilten
+// UserDefaults der Gruppe `group.app.datametric360`.
+// 3. Die Erweiterung öffnet die App über ihr eigenes URL-Schema.
+// 4. Die App liest den Eintrag beim Start bzw. beim Zurückkehren aus,
+// löscht ihn und lädt den Beleg hoch (daten/geteilterBeleg.ts).
+//
+// Base64 in UserDefaults statt einer Datei im Gruppen-Container: die App
+// liest UserDefaults ohnehin schon über @capacitor/preferences, das genau
+// diese Gruppe kennt. Eine Datei im Container bräuchte dagegen eigenen
+// nativen Code auf der App-Seite — ein zusätzliches Plugin für einen Weg,
+// den es sonst nicht gäbe.
+//
+// Der Preis ist eine Größengrenze: UserDefaults ist für Einstellungen
+// gedacht, nicht für Anhänge. GRENZE_BYTES hält das bei 4 MB. Ein
+// Tankbeleg liegt bei einigen Dutzend Kilobyte, ein gescanntes Mehrseiten-
+// PDF kann darüber liegen — dann sagt die Erweiterung das und der Weg über
+// „Datei auswählen" in der App bleibt.
+//
+// DIESE DATEI IST VERSIONIERT, ios/ NICHT.
+// `npx cap add ios` erzeugt den nativen Ordner neu und würde alles darin
+// verlieren. scripts/ios-teilen-einrichten.mjs kopiert diese Datei bei
+// jedem Bau wieder hinein und hängt das Ziel ins Xcode-Projekt.
+//
+
+import UIKit
+import Social
+import UniformTypeIdentifiers
+
+class ShareViewController: UIViewController {
+
+ /// Muss zur App-Gruppe in beiden Entitlements-Dateien passen.
+ private let gruppe = "group.app.datametric360"
+
+ /// Schlüssel, unter dem die App den Beleg abholt (geteilterBeleg.ts).
+ private let schluessel = "dm360_geteilter_beleg"
+
+ /// 4 MB. UserDefaults ist kein Dateispeicher — darüber wird abgelehnt,
+ /// statt das System mit einem Anhang zu belasten, für den es nicht
+ /// gedacht ist.
+ private let grenzeBytes = 4 * 1024 * 1024
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = .clear
+ pdfUebernehmen()
+ }
+
+ private func pdfUebernehmen() {
+ guard
+ let eintrag = (extensionContext?.inputItems as? [NSExtensionItem])?.first,
+ let anhaenge = eintrag.attachments
+ else {
+ return abschliessen()
+ }
+
+ let pdfTyp = UTType.pdf.identifier
+ guard let anhang = anhaenge.first(where: { $0.hasItemConformingToTypeIdentifier(pdfTyp) })
+ else {
+ return melden("Kein PDF dabei.")
+ }
+
+ anhang.loadItem(forTypeIdentifier: pdfTyp, options: nil) { [weak self] wert, _ in
+ guard let self else { return }
+
+ // Je nach Quelle kommt eine URL, ein Data-Objekt oder nichts
+ // Brauchbares an — Mail liefert eine URL auf eine temporäre
+ // Kopie, andere Apps reichen die Bytes direkt.
+ var daten: Data?
+ var name = "beleg.pdf"
+
+ if let url = wert as? URL {
+ daten = try? Data(contentsOf: url)
+ name = url.lastPathComponent
+ } else if let roh = wert as? Data {
+ daten = roh
+ }
+
+ guard let daten else {
+ return self.melden("Der Beleg ließ sich nicht lesen.")
+ }
+ guard daten.count <= self.grenzeBytes else {
+ return self.melden(
+ "Der Beleg ist zu groß für diesen Weg. In der App über „Beleg hochladen" → „Datei auswählen".")
+ }
+
+ self.ablegen(daten: daten, name: name)
+ }
+ }
+
+ private func ablegen(daten: Data, name: String) {
+ guard let ablage = UserDefaults(suiteName: gruppe) else {
+ return melden("Die App-Gruppe ist nicht eingerichtet.")
+ }
+ // Form wie in geteilterBeleg.ts erwartet. @capacitor/preferences legt
+ // Werte als String ab, deshalb JSON und kein Dictionary.
+ let inhalt: [String: Any] = [
+ "name": name,
+ "base64": daten.base64EncodedString(),
+ "zeit": Int(Date().timeIntervalSince1970 * 1000),
+ ]
+ guard
+ let json = try? JSONSerialization.data(withJSONObject: inhalt),
+ let text = String(data: json, encoding: .utf8)
+ else {
+ return melden("Der Beleg ließ sich nicht übergeben.")
+ }
+ ablage.set(text, forKey: schluessel)
+
+ DispatchQueue.main.async { [weak self] in
+ self?.appOeffnen()
+ self?.abschliessen()
+ }
+ }
+
+ /// Öffnet die Host-App. Eine Erweiterung darf `UIApplication.shared` nicht
+ /// benutzen, deshalb der Umweg über die Responder-Kette — der einzige von
+ /// Apple vorgesehene Weg aus einer Erweiterung heraus.
+ private func appOeffnen() {
+ guard let ziel = URL(string: "datametric360://beleg") else { return }
+ var antwort: UIResponder? = self
+ while let r = antwort {
+ if let app = r as? UIApplication {
+ app.open(ziel, options: [:], completionHandler: nil)
+ return
+ }
+ antwort = r.next
+ }
+ }
+
+ private func melden(_ text: String) {
+ DispatchQueue.main.async { [weak self] in
+ let hinweis = UIAlertController(title: "DataMetric360", message: text, preferredStyle: .alert)
+ hinweis.addAction(UIAlertAction(title: "Verstanden", style: .default) { _ in
+ self?.abschliessen()
+ })
+ self?.present(hinweis, animated: true)
+ }
+ }
+
+ private func abschliessen() {
+ extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
+ }
+}
diff --git a/companion-app/package.json b/companion-app/package.json
index 524f2bf..4643734 100644
--- a/companion-app/package.json
+++ b/companion-app/package.json
@@ -18,8 +18,12 @@
"@aparajita/capacitor-secure-storage": "^8.0.0",
"@audi-dash/ui": "*",
"@capacitor/android": "^8.5.0",
+ "@capacitor/app": "^8.1.1",
"@capacitor/core": "^8.5.0",
+ "@capacitor/filesystem": "^8.1.3",
"@capacitor/ios": "^8.5.0",
+ "@capacitor/preferences": "^8.0.1",
+ "@capacitor/share": "^8.0.1",
"@capgo/capacitor-updater": "^8.51.14",
"leaflet": "^1.9.4",
"react": "^18.3.1",
@@ -36,6 +40,7 @@
"jsdom": "^29.1.1",
"typescript": "^5.7.2",
"vite": "^6.0.7",
- "vitest": "^2.1.8"
+ "vitest": "^2.1.8",
+ "xcode": "^3.0.1"
}
}
diff --git a/companion-app/scripts/ios-signieren.sh b/companion-app/scripts/ios-signieren.sh
index 8ba751a..9f7a076 100755
--- a/companion-app/scripts/ios-signieren.sh
+++ b/companion-app/scripts/ios-signieren.sh
@@ -75,6 +75,49 @@ if [ ! -d ios ]; then
fi
npx cap sync ios
+# Standort-Berechtigung ins Info.plist eintragen.
+#
+# Ohne diesen Schluessel zeigt iOS beim Aufruf von navigator.geolocation in
+# der WebView (src/screens/Standort.tsx) ueberhaupt keine Rueckfrage: der
+# Aufruf scheitert sofort, die App meldet "GPS-Freigabe fehlt", und unter
+# Einstellungen > DataMetric360 > Ort fehlt die Auswahl "Beim Verwenden der
+# App" - es bleiben nur "Nie" und "Beim Teilen". Nachgewiesen am 2026-08-31
+# an der ausgelieferten App.ipa: kein einziger Location-Schluessel darin.
+#
+# Warum hier und nicht in Xcode: ios/ ist gitignored und wird von
+# `npx cap add ios` aus Capacitors Vorlage neu erzeugt - die kennt keine
+# Standort-Schluessel. Ein Eintrag von Hand in Xcode waere beim naechsten
+# Erzeugen wieder verschwunden, genau wie es der Team-Kennung oben ergangen
+# waere. Deshalb steht er versioniert hier und wird nach jedem `cap sync`
+# neu gesetzt.
+#
+# Nur "WhenInUse": berechnet wird der Abstand zum geparkten Fahrzeug, waehrend
+# jemand die Standort-Seite ansieht. Hintergrundortung braucht die App nicht,
+# und was sie nicht braucht, soll sie auch nicht erfragen.
+# Share-Erweiterung ins Xcode-Projekt haengen.
+#
+# Aus demselben Grund wie die Team-Kennung und der Standort-Schluessel oben:
+# ios/ ist gitignored, also verschwindet jedes von Hand angelegte Ziel beim
+# naechsten `npx cap add ios`. Die Quellen liegen versioniert unter native/,
+# das Skript spielt sie bei jedem Bau wieder ein. Es ist idempotent - ein
+# zweiter Lauf ueber ein fertiges Projekt aendert nichts.
+#
+# Schlaegt es fehl, bricht der ganze Bau ab: eine App ohne den Eintrag im
+# Teilen-Blatt sieht fertig aus und ist es nicht, und der Unterschied faellt
+# erst auf dem Geraet auf.
+echo "==> Share-Erweiterung einrichten"
+node scripts/ios-teilen-einrichten.mjs
+
+echo "==> Standort-Berechtigung eintragen"
+PLIST="ios/App/App/Info.plist"
+GRUND="Zeigt auf der Standort-Karte, wie weit du vom geparkten Fahrzeug entfernt bist."
+/usr/libexec/PlistBuddy -c "Set :NSLocationWhenInUseUsageDescription $GRUND" "$PLIST" 2>/dev/null \
+ || /usr/libexec/PlistBuddy -c "Add :NSLocationWhenInUseUsageDescription string $GRUND" "$PLIST"
+/usr/libexec/PlistBuddy -c "Print :NSLocationWhenInUseUsageDescription" "$PLIST" >/dev/null || {
+ echo "FEHLER: NSLocationWhenInUseUsageDescription liess sich nicht setzen." >&2
+ exit 1
+}
+
echo "==> Archiv bauen und signieren (Team $TEAM)"
rm -rf "$AUSGABE"
mkdir -p "$AUSGABE"
diff --git a/companion-app/scripts/ios-teilen-einrichten.mjs b/companion-app/scripts/ios-teilen-einrichten.mjs
new file mode 100644
index 0000000..04d6311
--- /dev/null
+++ b/companion-app/scripts/ios-teilen-einrichten.mjs
@@ -0,0 +1,198 @@
+#!/usr/bin/env node
+/**
+ * Hängt die Share-Erweiterung ins erzeugte Xcode-Projekt.
+ *
+ * WARUM ES DIESES SKRIPT GIBT
+ * ---------------------------
+ * `companion-app/ios/` ist absichtlich gitignored — `npx cap add ios` baut den
+ * Ordner aus dem Webbündel jederzeit neu. Damit verschwindet aber auch jedes
+ * Ziel, das man in Xcode von Hand anlegt. Dieselbe Falle, die schon die
+ * Team-Kennung und den Standort-Schlüssel in `ios-signieren.sh` getrieben hat:
+ * was den nächsten `cap add` überleben soll, muss versioniert sein und bei
+ * jedem Bau neu eingespielt werden.
+ *
+ * Die Quellen liegen deshalb unter `companion-app/native/`; dieses Skript
+ * kopiert sie nach `ios/App/` und trägt das Erweiterungsziel in die
+ * `project.pbxproj` ein.
+ *
+ * WAS ES ANLEGT
+ * -------------
+ * - Ziel `DataMetric360Share` (App-Erweiterung, Bundle-ID
+ * `app.datametric360.teilen`)
+ * - dessen Quelldateien, Info.plist und Entitlements
+ * - die App-Gruppe `group.app.datametric360` auf BEIDEN Zielen
+ * - die Einbettung der Erweiterung in die App (Build-Phase
+ * „Embed App Extensions")
+ * - das URL-Schema `datametric360://`, über das die Erweiterung die App holt
+ *
+ * IDEMPOTENT
+ * ----------
+ * Ein zweiter Lauf über ein bereits eingerichtetes Projekt ändert nichts und
+ * meldet das. Wichtig, weil `ios-signieren.sh` bei jedem Bau durchläuft und
+ * `ios/` nicht immer neu erzeugt wird.
+ *
+ * NICHT GETESTET AUF EINEM MAC
+ * ----------------------------
+ * Geschrieben ohne Zugriff auf macOS/Xcode. Die pbxproj-Bearbeitung folgt der
+ * API des npm-Pakets `xcode`; scheitert sie, bricht das Skript mit einer
+ * klaren Meldung ab und `docs/SHARE_EXTENSION.md` beschreibt die Handgriffe,
+ * die dasselbe in Xcode erledigen. Lieber ein lauter Abbruch mit Anleitung
+ * als ein halb eingerichtetes Projekt.
+ */
+
+import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from "node:fs"
+import { dirname, join } from "node:path"
+import { fileURLToPath } from "node:url"
+
+import xcode from "xcode"
+
+const HIER = dirname(fileURLToPath(import.meta.url))
+const APP = dirname(HIER)
+
+const ZIEL_NAME = "DataMetric360Share"
+const BUNDLE_ID = "app.datametric360.teilen"
+const GRUPPE = "group.app.datametric360"
+const SCHEMA = "datametric360"
+
+const iosApp = join(APP, "ios", "App")
+const pbxPfad = join(iosApp, "App.xcodeproj", "project.pbxproj")
+const quelleErw = join(APP, "native", "teilen")
+const zielErw = join(iosApp, ZIEL_NAME)
+
+function melden(text) {
+ console.log(" " + text)
+}
+
+function abbruch(text) {
+ console.error("")
+ console.error("FEHLER: " + text)
+ console.error("")
+ console.error("Die Share-Erweiterung ist damit NICHT eingerichtet. Die App selbst")
+ console.error("baut und läuft weiterhin - nur der Eintrag im Teilen-Blatt fehlt.")
+ console.error("Von Hand nachziehen: companion-app/docs/SHARE_EXTENSION.md")
+ process.exit(1)
+}
+
+if (!existsSync(pbxPfad)) {
+ abbruch(`${pbxPfad} nicht gefunden - erst 'npx cap sync ios' laufen lassen.`)
+}
+
+/* ---------------------------------------------------------------- Dateien */
+
+mkdirSync(zielErw, { recursive: true })
+for (const datei of ["ShareViewController.swift", "Info.plist", `${ZIEL_NAME}.entitlements`]) {
+ copyFileSync(join(quelleErw, datei), join(zielErw, datei))
+}
+copyFileSync(join(APP, "native", "App.entitlements"), join(iosApp, "App", "App.entitlements"))
+melden(`Quellen nach ios/App/${ZIEL_NAME}/ kopiert`)
+
+/* ------------------------------------------------------------- URL-Schema */
+
+/* Ohne dieses Schema kann die Erweiterung die App nicht holen - sie legt den
+ Beleg zwar ab, aber niemand merkt es, bis die App das nächste Mal von Hand
+ geöffnet wird. */
+const appPlistPfad = join(iosApp, "App", "Info.plist")
+let appPlist = readFileSync(appPlistPfad, "utf8")
+if (!appPlist.includes("CFBundleURLTypes")) {
+ const eintrag = [
+ "\tCFBundleURLTypes ",
+ "\t",
+ "\t\t",
+ "\t\t\tCFBundleURLName ",
+ `\t\t\tapp.datametric360 `,
+ "\t\t\tCFBundleURLSchemes ",
+ "\t\t\t",
+ `\t\t\t\t${SCHEMA} `,
+ "\t\t\t ",
+ "\t\t ",
+ "\t ",
+ "",
+ ].join("\n")
+ const letztes = appPlist.lastIndexOf("")
+ if (letztes < 0) abbruch("ios/App/App/Info.plist hat keine schließende -Marke.")
+ appPlist = appPlist.slice(0, letztes) + eintrag + appPlist.slice(letztes + " ".length)
+ writeFileSync(appPlistPfad, appPlist, "utf8")
+ melden(`URL-Schema ${SCHEMA}:// eingetragen`)
+} else {
+ melden("URL-Schema war bereits eingetragen")
+}
+
+/* ---------------------------------------------------------------- Projekt */
+
+const projekt = xcode.project(pbxPfad)
+projekt.parseSync()
+
+const vorhanden = Object.values(projekt.pbxNativeTargetSection()).some(
+ (z) => z && typeof z === "object" && z.name === ZIEL_NAME,
+)
+if (vorhanden) {
+ melden(`Ziel ${ZIEL_NAME} war bereits im Projekt - nichts geändert`)
+ process.exit(0)
+}
+
+let ziel
+try {
+ ziel = projekt.addTarget(ZIEL_NAME, "app_extension", ZIEL_NAME, BUNDLE_ID)
+
+ // Eigene Build-Phasen: ohne sie hat das Ziel keine Quelldateien und keine
+ // Ressourcen, und Xcode baut ein leeres Bündel.
+ projekt.addBuildPhase([], "PBXSourcesBuildPhase", "Sources", ziel.uuid)
+ projekt.addBuildPhase([], "PBXResourcesBuildPhase", "Resources", ziel.uuid)
+ projekt.addBuildPhase([], "PBXFrameworksBuildPhase", "Frameworks", ziel.uuid)
+
+ const gruppe = projekt.addPbxGroup(
+ ["ShareViewController.swift", "Info.plist", `${ZIEL_NAME}.entitlements`],
+ ZIEL_NAME,
+ ZIEL_NAME,
+ )
+ // In die Projektwurzel einhängen, sonst taucht die Gruppe in Xcode nicht auf.
+ const wurzel = Object.keys(projekt.hash.project.objects.PBXGroup).find(
+ (k) => projekt.hash.project.objects.PBXGroup[k].name === "CustomTemplate",
+ )
+ if (wurzel) projekt.addToPbxGroup(gruppe.uuid, wurzel)
+
+ projekt.addSourceFile("ShareViewController.swift", { target: ziel.uuid }, gruppe.uuid)
+
+ // Einstellungen, die `addTarget` nicht selbst setzt.
+ const konfigs = projekt.pbxXCBuildConfigurationSection()
+ for (const schluessel of Object.keys(konfigs)) {
+ const eintrag = konfigs[schluessel]
+ if (!eintrag || typeof eintrag !== "object" || !eintrag.buildSettings) continue
+ const s = eintrag.buildSettings
+ if (s.PRODUCT_BUNDLE_IDENTIFIER === `"${BUNDLE_ID}"` || s.PRODUCT_NAME === `"${ZIEL_NAME}"`) {
+ s.INFOPLIST_FILE = `"${ZIEL_NAME}/Info.plist"`
+ s.CODE_SIGN_ENTITLEMENTS = `"${ZIEL_NAME}/${ZIEL_NAME}.entitlements"`
+ s.IPHONEOS_DEPLOYMENT_TARGET = "14.0"
+ s.SWIFT_VERSION = "5.0"
+ s.TARGETED_DEVICE_FAMILY = `"1,2"`
+ s.SKIP_INSTALL = "YES"
+ }
+ // Die App selbst braucht ihre neue Entitlements-Datei.
+ if (s.PRODUCT_BUNDLE_IDENTIFIER === `"app.datametric360"`) {
+ s.CODE_SIGN_ENTITLEMENTS = `"App/App.entitlements"`
+ }
+ }
+
+ // Erweiterung in die App einbetten - ohne diese Phase liegt sie zwar im
+ // Projekt, wandert aber nicht mit ins fertige .ipa.
+ projekt.addBuildPhase(
+ [`${ZIEL_NAME}.appex`],
+ "PBXCopyFilesBuildPhase",
+ "Embed App Extensions",
+ projekt.getFirstTarget().uuid,
+ "app_extension",
+ )
+
+ writeFileSync(pbxPfad, projekt.writeSync(), "utf8")
+} catch (fehler) {
+ abbruch(`Das Xcode-Projekt ließ sich nicht bearbeiten: ${fehler.message}`)
+}
+
+melden(`Ziel ${ZIEL_NAME} angelegt und eingebettet`)
+melden(`App-Gruppe ${GRUPPE} auf beiden Zielen`)
+console.log("")
+console.log(" Einmalig im Apple-Entwicklerportal nötig, sonst schlägt das Signieren fehl:")
+console.log(` - App-Gruppe ${GRUPPE} anlegen`)
+console.log(` - App-ID ${BUNDLE_ID} anlegen und der Gruppe zuordnen`)
+console.log(` - App-ID app.datametric360 ebenfalls der Gruppe zuordnen`)
+console.log("")
diff --git a/companion-app/src/App.tsx b/companion-app/src/App.tsx
index c74648d..ed67e95 100644
--- a/companion-app/src/App.tsx
+++ b/companion-app/src/App.tsx
@@ -1,7 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react"
+import { App as CapacitorApp } from "@capacitor/app"
+
import { DataMetricApi, zugangLesen } from "./api"
import { DatenAnbieter, useDaten } from "./daten/DatenKontext"
+import { geteiltenBelegAbholen, geteiltenBelegSetzen } from "./daten/geteilterBeleg"
import { startklarMelden } from "./daten/ota"
import { Shell } from "./Shell"
import type { Route, SeitenName } from "./navigation"
@@ -75,6 +78,35 @@ function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) {
void startklarMelden()
}, [])
+ /* Belege aus dem Teilen-Blatt.
+
+ Die Erweiterung legt das PDF ab und oeffnet die App - beim ersten Start
+ kommt die App dadurch frisch hoch, bei einer schon laufenden App nur
+ nach vorn. Deshalb beide Faelle: einmal beim Aufbau, und danach bei
+ jeder Rueckkehr in den Vordergrund.
+
+ Der Beleg wandert in den geteilten Speicher und die App springt auf
+ Tanken - dort nimmt ihn Tanken.tsx auf und laedt ihn hoch, ueber
+ genau denselben Weg wie eine von Hand gewaehlte Datei. */
+ useEffect(() => {
+ let abgemeldet = false
+
+ const holen = async () => {
+ const datei = await geteiltenBelegAbholen()
+ if (abgemeldet || !datei) return
+ geteiltenBelegSetzen(datei)
+ geheZu("fuel")
+ }
+
+ void holen()
+
+ const horcher = CapacitorApp.addListener("resume", () => void holen())
+ return () => {
+ abgemeldet = true
+ void horcher.then((h) => h.remove())
+ }
+ }, [geheZu])
+
return (
- ) : null}
+ ) : (
+ /* Gegengewicht zum Zurueck-Pfeil.
+
+ Der Titel ist auf Unterseiten mittig gesetzt - aber nur innerhalb
+ seines eigenen Kastens, und der beginnt erst hinter dem
+ Zurueck-Pfeil. Ohne ein gleich breites Element auf der rechten
+ Seite sass die Ueberschrift deshalb um dessen halbe Breite zu
+ weit rechts: live gemessen 17px Versatz auf "Standort"
+ (2026-08-31), und zwar auf jeder Unterseite, nicht nur dort.
+
+ Ein leerer Platzhalter statt einer absoluten Positionierung: so
+ behaelt ein langer Titel weiterhin seinen eigenen Platz und
+ schiebt sich nicht unter die Knoepfe. */
+
+ )}
)
diff --git a/companion-app/src/daten/geteilterBeleg.test.ts b/companion-app/src/daten/geteilterBeleg.test.ts
new file mode 100644
index 0000000..12f9494
--- /dev/null
+++ b/companion-app/src/daten/geteilterBeleg.test.ts
@@ -0,0 +1,95 @@
+/**
+ * Die Auswertung eines geteilten Belegs — der Teil, der ohne Gerät prüfbar
+ * ist. Das Ablegen selbst passiert nativ (ShareViewController.swift) und
+ * kann hier nicht nachgestellt werden; geprüft wird, was die App mit dem
+ * abgelegten Eintrag macht.
+ *
+ * `@capacitor/core` und `@capacitor/preferences` werden ersetzt: das eine
+ * meldet sonst „kein natives Gerät" und die Abholung steigt sofort aus, das
+ * andere greift auf eine native Ablage zu, die es hier nicht gibt.
+ */
+
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const ablage = new Map()
+
+vi.mock("@capacitor/core", () => ({
+ Capacitor: { isNativePlatform: () => true },
+}))
+
+vi.mock("@capacitor/preferences", () => ({
+ Preferences: {
+ configure: vi.fn(async () => {}),
+ get: vi.fn(async ({ key }: { key: string }) => ({ value: ablage.get(key) ?? null })),
+ remove: vi.fn(async ({ key }: { key: string }) => {
+ ablage.delete(key)
+ }),
+ },
+}))
+
+const SCHLUESSEL = "dm360_geteilter_beleg"
+
+/** Ein winziges, gültiges PDF als Base64 — Inhalt ist hier gleichgültig. */
+const PDF_BASE64 = btoa("%PDF-1.4\n%%EOF\n")
+
+function ablegen(eintrag: Record) {
+ ablage.set(SCHLUESSEL, JSON.stringify(eintrag))
+}
+
+describe("geteilten Beleg abholen", () => {
+ beforeEach(() => {
+ ablage.clear()
+ vi.resetModules()
+ })
+
+ it("liefert nichts, wenn keiner wartet", async () => {
+ const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
+ expect(await geteiltenBelegAbholen()).toBeNull()
+ })
+
+ it("baut aus dem Eintrag eine PDF-Datei", async () => {
+ ablegen({ name: "tankbeleg.pdf", base64: PDF_BASE64, zeit: Date.now() })
+ const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
+
+ const datei = await geteiltenBelegAbholen()
+
+ expect(datei).not.toBeNull()
+ expect(datei!.name).toBe("tankbeleg.pdf")
+ expect(datei!.type).toBe("application/pdf")
+ expect(datei!.size).toBeGreaterThan(0)
+ })
+
+ it("räumt den Eintrag weg, damit derselbe Beleg nicht zweimal ankommt", async () => {
+ ablegen({ name: "a.pdf", base64: PDF_BASE64, zeit: Date.now() })
+ const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
+
+ expect(await geteiltenBelegAbholen()).not.toBeNull()
+ expect(await geteiltenBelegAbholen()).toBeNull()
+ })
+
+ it("verwirft einen liegengebliebenen Eintrag", async () => {
+ // Älter als zehn Minuten: das Öffnen der App aus der Erweiterung heraus
+ // ist damals fehlgeschlagen. Ihn jetzt als neuen Beleg zu behandeln wäre
+ // eine Überraschung, keine Hilfe.
+ ablegen({ name: "alt.pdf", base64: PDF_BASE64, zeit: Date.now() - 11 * 60 * 1000 })
+ const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
+
+ expect(await geteiltenBelegAbholen()).toBeNull()
+ })
+
+ it("verwirft unlesbaren Inhalt, statt daran hängenzubleiben", async () => {
+ ablage.set(SCHLUESSEL, "{kein json")
+ const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
+
+ expect(await geteiltenBelegAbholen()).toBeNull()
+ // Auch der kaputte Eintrag ist weg - sonst liefe er bei jedem Start neu auf.
+ expect(ablage.has(SCHLUESSEL)).toBe(false)
+ })
+
+ it("nimmt einen Standardnamen, wenn keiner mitkam", async () => {
+ ablegen({ base64: PDF_BASE64, zeit: Date.now() })
+ const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
+
+ expect((await geteiltenBelegAbholen())!.name).toBe("beleg.pdf")
+ })
+})
diff --git a/companion-app/src/daten/geteilterBeleg.ts b/companion-app/src/daten/geteilterBeleg.ts
new file mode 100644
index 0000000..32439b0
--- /dev/null
+++ b/companion-app/src/daten/geteilterBeleg.ts
@@ -0,0 +1,122 @@
+/**
+ * Belege, die über das iOS-Teilen-Blatt hereinkommen.
+ *
+ * Die Gegenseite ist `native/teilen/ShareViewController.swift`: sie legt das
+ * geteilte PDF Base64-kodiert in den UserDefaults der App-Gruppe
+ * `group.app.datametric360` ab und öffnet danach die App über
+ * `datametric360://beleg`.
+ *
+ * `@capacitor/preferences` liest genau diese UserDefaults, sobald man ihm die
+ * Gruppe nennt — deshalb braucht es hier kein eigenes Plugin. Ohne die Gruppe
+ * läse es den privaten Bereich der App und fände nie etwas.
+ *
+ * ABGEHOLT WIRD GENAU EINMAL
+ * --------------------------
+ * Der Eintrag wird nach dem Lesen gelöscht. Bliebe er stehen, käme derselbe
+ * Beleg bei jedem Zurückkehren in die App erneut — und weil der Upload beim
+ * Backend einen Vorgang anlegt, wären das echte Doppeleinträge, keine bloße
+ * Anzeigewiederholung.
+ *
+ * Der Zeitstempel ist die zweite Sicherung: ein Eintrag, der älter ist als
+ * HOECHSTALTER_MS, wird verworfen statt verarbeitet. Er kann nur entstehen,
+ * wenn das Öffnen der App aus der Erweiterung heraus fehlgeschlagen ist; ihn
+ * Tage später beim nächsten Start als „neuen" Beleg zu behandeln, wäre eine
+ * Überraschung, keine Hilfe.
+ */
+
+import { Capacitor } from "@capacitor/core"
+import { Preferences } from "@capacitor/preferences"
+
+/** Muss zu `gruppe` in ShareViewController.swift passen. */
+const GRUPPE = "group.app.datametric360"
+
+/** Muss zu `schluessel` in ShareViewController.swift passen. */
+const SCHLUESSEL = "dm360_geteilter_beleg"
+
+/** Zehn Minuten. Länger her heißt: das Öffnen der App hat nicht geklappt. */
+const HOECHSTALTER_MS = 10 * 60 * 1000
+
+let eingerichtet = false
+
+async function gruppeEinrichten(): Promise {
+ if (eingerichtet) return
+ eingerichtet = true
+ try {
+ await Preferences.configure({ group: GRUPPE })
+ } catch (fehler) {
+ // Auf Android und im Browser gibt es keine App-Gruppe; das ist kein
+ // Fehler, dort kommt über diesen Weg nur nie etwas an.
+ console.warn("Geteilter Beleg: Gruppe nicht einrichtbar", fehler)
+ }
+}
+
+function base64ZuDatei(base64: string, name: string): File {
+ const roh = atob(base64)
+ const bytes = new Uint8Array(roh.length)
+ for (let i = 0; i < roh.length; i++) bytes[i] = roh.charCodeAt(i)
+ return new File([bytes], name, { type: "application/pdf" })
+}
+
+/**
+ * Holt einen wartenden Beleg ab und räumt ihn weg. `null`, wenn keiner da ist
+ * — der Normalfall bei jedem gewöhnlichen App-Start.
+ */
+export async function geteiltenBelegAbholen(): Promise {
+ if (!Capacitor.isNativePlatform()) return null
+ await gruppeEinrichten()
+
+ let roh: string | null = null
+ try {
+ roh = (await Preferences.get({ key: SCHLUESSEL })).value
+ } catch (fehler) {
+ console.warn("Geteilter Beleg: nicht lesbar", fehler)
+ return null
+ }
+ if (!roh) return null
+
+ // Erst wegräumen, dann auswerten: ein unlesbarer Eintrag darf nicht bei
+ // jedem Start erneut auflaufen.
+ try {
+ await Preferences.remove({ key: SCHLUESSEL })
+ } catch {
+ // Bleibt er stehen, fängt ihn beim nächsten Mal die Altersprüfung ab.
+ }
+
+ try {
+ const eintrag = JSON.parse(roh) as { name?: string; base64?: string; zeit?: number }
+ if (!eintrag.base64) return null
+ if (typeof eintrag.zeit === "number" && Date.now() - eintrag.zeit > HOECHSTALTER_MS) {
+ return null
+ }
+ return base64ZuDatei(eintrag.base64, eintrag.name || "beleg.pdf")
+ } catch (fehler) {
+ console.warn("Geteilter Beleg: unlesbarer Eintrag verworfen", fehler)
+ return null
+ }
+}
+
+/* ------------------------------------------------------------- Übergabe
+
+ Zwischen dem Abholen (App.tsx, beim Start und bei jeder Rückkehr) und dem
+ Verarbeiten (Tanken.tsx) liegt ein Bildschirmwechsel. Ein kleiner geteilter
+ Speicher nach dem Muster von theme.ts überbrückt ihn — ein Zustand, der
+ außerhalb des Komponentenbaums lebt und den beide Seiten sehen. */
+
+let wartend: File | null = null
+const horcher = new Set<() => void>()
+
+export function geteiltenBelegSetzen(datei: File | null): void {
+ wartend = datei
+ for (const bei of horcher) bei()
+}
+
+export function geteiltenBelegStand(): File | null {
+ return wartend
+}
+
+export function geteiltenBelegHorchen(bei: () => void): () => void {
+ horcher.add(bei)
+ return () => {
+ horcher.delete(bei)
+ }
+}
diff --git a/companion-app/src/daten/profilAdapter.ts b/companion-app/src/daten/profilAdapter.ts
index c67c5a8..f4b27ae 100644
--- a/companion-app/src/daten/profilAdapter.ts
+++ b/companion-app/src/daten/profilAdapter.ts
@@ -133,7 +133,8 @@ export function profilZuEinstellungen(profil: Profil): Einstellungen {
fahrzeugzusatz: text(fahrzeug, "zusatz"),
fahrzeuguntertitel: text(fahrzeug, "untertitel"),
kennzeichen: text(fahrzeug, "kennzeichen"),
- startbild: text(einst, "uebersichtsbild", "seitenansicht.webp"),
+ // Der Name der Ansicht, nicht der Dateiname - so speichert es das Panel.
+ startbild: text(einst, "uebersichtsbild", "Seitenansicht"),
tankvolumen: zahl(fahrzeug, "tankvolumen_liter") ?? 58,
nachtVon: text(einst, "nacht_von", "22:00"),
nachtBis: text(einst, "nacht_bis", "06:00"),
diff --git a/companion-app/src/daten/zwischenablage.ts b/companion-app/src/daten/zwischenablage.ts
new file mode 100644
index 0000000..dc1c50e
--- /dev/null
+++ b/companion-app/src/daten/zwischenablage.ts
@@ -0,0 +1,73 @@
+/**
+ * Beleg-PDF aus der Zwischenablage oder einer Dateiliste.
+ *
+ * Übernommen aus dem Panel (`belegAusZwischenablage()`, `istPdf()`,
+ * `zwischenablageKnopfSinnvoll()` in audi-dashboard-app.js) — dort gibt es
+ * diesen Weg seit längerem, in der App fehlte er ganz: der Beleg-Knopf öffnete
+ * direkt die Dateiauswahl, und ein aus der Mail kopiertes PDF ließ sich
+ * überhaupt nicht einfügen (gemeldet vom Eigentümer, 2026-08-31).
+ *
+ * WARUM DER KNOPF NUR AUF TIPPGERÄTEN ERSCHEINT
+ * ---------------------------------------------
+ * `navigator.clipboard.read()` gibt aus Datenschutzgründen nur Text, HTML und
+ * Bilder heraus. Eine im Explorer oder Finder kopierte Datei taucht dort nie
+ * auf — am Rechner scheitert der Weg also zwangsläufig, und ein Knopf, der
+ * immer fehlschlägt, ist schlimmer als kein Knopf. Dort bleiben Ziehen und
+ * Dateiauswahl.
+ *
+ * Auf iOS ist es umgekehrt der Hauptweg: das PDF kommt aus Mail, wird dort
+ * kopiert, und das System zeigt beim Lesen seine eigene Einfügen-Abfrage.
+ */
+
+/** Nicht nur auf den MIME-Typ verlassen: eine aus dem Explorer/Finder kopierte
+ oder gezogene Datei kommt je nach Browser und Quelle mit leerem `type` an. */
+export function istPdf(datei: File | null | undefined): boolean {
+ if (!datei) return false
+ return datei.type === "application/pdf" || /\.pdf$/i.test(datei.name || "")
+}
+
+/** Nimmt das erste PDF aus einer DataTransfer-/Dateiliste. */
+export function pdfAusDateiliste(dateien: FileList | File[] | null | undefined): File | null {
+ return [...(dateien ?? [])].find(istPdf) ?? null
+}
+
+export class ZwischenablageFehler extends Error {}
+
+/**
+ * Holt ein PDF aus der Zwischenablage.
+ *
+ * Wirft mit einem Text, der zur jeweiligen Ursache die richtige Abhilfe nennt —
+ * "bitte Datei auswählen" hilft nicht weiter, wenn schlicht kein PDF kopiert
+ * wurde, und "erneut kopieren" nicht, wenn der Browser die Zwischenablage gar
+ * nicht herausgibt.
+ */
+export async function pdfAusZwischenablage(): Promise {
+ if (!navigator.clipboard?.read) {
+ throw new ZwischenablageFehler(
+ 'Dieser Browser gibt die Zwischenablage nicht heraus - bitte "Datei auswählen".',
+ )
+ }
+ let eintraege: ClipboardItems
+ try {
+ eintraege = await navigator.clipboard.read()
+ } catch {
+ // Verweigerte Freigabe, abgebrochene System-Abfrage, unsicherer Ursprung.
+ throw new ZwischenablageFehler('Kein Zugriff auf die Zwischenablage - bitte "Datei auswählen".')
+ }
+ for (const eintrag of eintraege) {
+ const typ = eintrag.types.find((t) => t === "application/pdf")
+ if (typ) {
+ const blob = await eintrag.getType(typ)
+ // Ein Name muss sein: das Backend legt den Beleg unter ihm ab.
+ return new File([blob], "beleg.pdf", { type: "application/pdf" })
+ }
+ }
+ throw new ZwischenablageFehler(
+ 'In der Zwischenablage liegt kein PDF - bitte den Beleg erneut kopieren oder "Datei auswählen".',
+ )
+}
+
+/** Tippgerät? Nur dort wird der Einfügen-Knopf angeboten (Begründung oben). */
+export function zwischenablageKnopfSinnvoll(): boolean {
+ return typeof window !== "undefined" && !!window.matchMedia?.("(pointer: coarse)").matches
+}
diff --git a/companion-app/src/screens/BelegPopup.tsx b/companion-app/src/screens/BelegPopup.tsx
new file mode 100644
index 0000000..80b1908
--- /dev/null
+++ b/companion-app/src/screens/BelegPopup.tsx
@@ -0,0 +1,129 @@
+/**
+ * „Beleg hochladen" — Vorlage: `vBelegPopup()` im Panel.
+ *
+ * Bis 2026-08-31 öffnete der Beleg-Knopf in der App direkt die Dateiauswahl.
+ * Auf dem iPhone landete man damit im Dateispeicher, und ein aus der Mail
+ * kopiertes PDF war überhaupt nicht einzufügen — im Panel gab es diesen Weg
+ * längst. Geteilt zwischen dem Anlegen (Tanken.tsx) und dem Nachtragen
+ * (TankDetail.tsx), damit beide nicht auseinanderlaufen.
+ *
+ * Ein Auswahlblatt, kein Popup: es ist eine kurze Liste von Wegen zum selben
+ * Ziel, und genau dafür sieht Apple das Aktionsblatt vor. „Abbrechen" steht
+ * abgesetzt in eigener Karte — das macht es zum Ausweg statt zur vierten
+ * Wahlmöglichkeit.
+ *
+ * Welche Wege angeboten werden, entscheidet das Gerät, nicht der Geschmack:
+ * am Telefon ist Einfügen der Hauptweg, am Rechner Ziehen bzw. Dateiauswahl.
+ * Begründung in daten/zwischenablage.ts.
+ */
+
+import { useRef, useState } from "react"
+
+import { ActionSheet } from "@audi-dash/ui"
+
+import {
+ ZwischenablageFehler,
+ pdfAusDateiliste,
+ pdfAusZwischenablage,
+ zwischenablageKnopfSinnvoll,
+} from "../daten/zwischenablage"
+
+export function BelegPopup({
+ offen,
+ beiAbbruch,
+ beiDatei,
+}: {
+ offen: boolean
+ beiAbbruch: () => void
+ /** Bekommt das PDF. Das Blatt schließt sich vorher selbst. */
+ beiDatei: (datei: File) => void
+}) {
+ const dateiwahl = useRef(null)
+ const [fehler, setzeFehler] = useState(null)
+ const [darueber, setzeDarueber] = useState(false)
+ // Einmal beim Aufbau bestimmt: ein Gerätewechsel mitten im Dialog gibt es
+ // nicht, und ein bei jedem Rendern neu befragtes matchMedia wäre nur Aufwand.
+ const [telefon] = useState(zwischenablageKnopfSinnvoll)
+
+ const uebernehmen = (datei: File) => {
+ setzeFehler(null)
+ beiDatei(datei)
+ }
+
+ const einfuegen = async () => {
+ try {
+ uebernehmen(await pdfAusZwischenablage())
+ } catch (f) {
+ setzeFehler(
+ f instanceof ZwischenablageFehler
+ ? f.message
+ : 'Die Zwischenablage ließ sich nicht lesen - bitte "Datei auswählen".',
+ )
+ }
+ }
+
+ const aktionen = [
+ ...(telefon
+ ? [{ label: "Aus Zwischenablage einfügen", onClick: () => void einfuegen() }]
+ : []),
+ { label: "Datei auswählen", onClick: () => dateiwahl.current?.click() },
+ ]
+
+ // Nur wenn es wirklich etwas zu zeigen gibt - sonst entstuende im Blatt
+ // eine leere Zeile samt Trennlinie.
+ const zusatz =
+ !telefon || fehler ? (
+ <>
+ {!telefon && (
+ {
+ e.preventDefault()
+ setzeDarueber(true)
+ }}
+ onDragLeave={() => setzeDarueber(false)}
+ onDrop={(e) => {
+ e.preventDefault()
+ setzeDarueber(false)
+ const datei = pdfAusDateiliste(e.dataTransfer?.files)
+ if (datei) uebernehmen(datei)
+ else setzeFehler("Das war kein PDF - der Beleg muss als PDF vorliegen.")
+ }}
+ >
+ PDF hierher ziehen
+ oder unten auswählen
+
+ )}
+ {fehler && (
+
+ {fehler}
+
+ )}
+ >
+ ) : undefined
+
+ return (
+ <>
+ {/* Ausserhalb des Blattes: unsichtbar, muss aber im Baum stehen, damit
+ der Verweis darauf zeigt. */}
+ {
+ const datei = e.target.files?.[0]
+ e.target.value = ""
+ if (datei) uebernehmen(datei)
+ }}
+ />
+ {/* Ohne Ueberschrift und ohne Erklaerzeile: wer den Knopf 'Beleg
+ hochladen' gedrueckt hat, weiss bereits, was hier passiert - der
+ Kopf haette es nur wiederholt (Nutzerhinweis 2026-08-31). Die
+ beiden Wege benennen sich selbst. */}
+
+ {zusatz}
+
+ >
+ )
+}
diff --git a/companion-app/src/screens/DatensatzPopup.tsx b/companion-app/src/screens/DatensatzPopup.tsx
index bc2015b..e3cb068 100644
--- a/companion-app/src/screens/DatensatzPopup.tsx
+++ b/companion-app/src/screens/DatensatzPopup.tsx
@@ -23,7 +23,7 @@
import { useEffect, useRef, useState } from "react"
-import { ActionButton, Popup } from "@audi-dash/ui"
+import { ActionButton, Sheet } from "@audi-dash/ui"
import type { CsvImportErgebnis, DataMetricApi, Fahrt, Profil, Tankvorgang } from "../api"
import { de, eur } from "../format"
@@ -189,8 +189,13 @@ export function DatensatzPopup({
}
return (
-
- {modus === "sichern" ? "Datensatz sichern" : "Datensatz laden"}
+ /* Blatt statt schwebender Karte - Apples Vorgabe fuer eine vom Nutzer
+ angestossene Aufgabe auf dem Telefon. */
+
{modus === "sichern"
? "Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in Tabellenprogrammen aufgehen."
@@ -237,7 +242,7 @@ export function DatensatzPopup({
Fertig
-
+
)
}
diff --git a/companion-app/src/screens/Datumsfeld.tsx b/companion-app/src/screens/Datumsfeld.tsx
new file mode 100644
index 0000000..a5eabf8
--- /dev/null
+++ b/companion-app/src/screens/Datumsfeld.tsx
@@ -0,0 +1,58 @@
+/**
+ * Ein Datumsfeld, das seinen Wert auch dann anzeigt, wenn er erst später
+ * eintrifft.
+ *
+ * DAS PROBLEM
+ * -----------
+ * Alle Datumsfelder der App hängen an Werten aus dem Datenkontext, und der
+ * füllt sich asynchron: beim ersten Rendern steht dort ein leerer String, den
+ * echten Wert bekommt das Feld erst danach. WebKit — die Engine der iOS-Hülle —
+ * baut den inneren Aufbau eines ` ` beim ersten Rendern auf
+ * und zieht ihn bei einer späteren Wertänderung von außen nicht zuverlässig
+ * nach; sichtbar blieb „tt.mm.jjjj", und erst ein Antippen brachte das Datum
+ * zum Vorschein (gemeldet vom Eigentümer, 2026-08-31: „erst beim ersten Klick
+ * aufs Datumsfeld erscheinen diese korrekt").
+ *
+ * Verschärft wird das durch den `appearance`-Reset in screens.css, der für die
+ * Box-Optik nötig ist — ohne ihn zeigt iOS sein eigenes, stark abgerundetes
+ * Chrome („gestreckter Kreis statt Box").
+ *
+ * DIE LÖSUNG
+ * ----------
+ * Der Schlüssel wechselt genau einmal: von „leer" auf „gefuellt", sobald der
+ * echte Wert da ist. React baut das Feld an dieser Stelle neu auf, und WebKit
+ * legt seinen inneren Aufbau damit gleich mit dem Wert an.
+ *
+ * Bewusst NICHT der Wert selbst als Schlüssel: dann entstünde das Feld bei
+ * jeder Auswahl neu, verlöre den Fokus und schlösse auf dem Telefon die
+ * Datumsauswahl mitten in der Bedienung. Der Wechsel leer→gefüllt passiert
+ * dagegen genau einmal, und zwar bevor jemand das Feld anfassen kann.
+ *
+ * Das Panel braucht das nicht: dort schreibt jedes `render()` das Markup neu,
+ * das Feld entsteht also ohnehin immer mit seinem Wert.
+ */
+
+export function Datumsfeld({
+ wert,
+ beiAenderung,
+ className = "dm-eingabe",
+ ...rest
+}: {
+ wert: string
+ beiAenderung: (wert: string) => void
+ className?: string
+} & Omit<
+ React.InputHTMLAttributes,
+ "value" | "onChange" | "type" | "className"
+>) {
+ return (
+ beiAenderung(e.target.value)}
+ {...rest}
+ />
+ )
+}
diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx
index 22e9ac9..4ea8090 100644
--- a/companion-app/src/screens/Einstellungen.tsx
+++ b/companion-app/src/screens/Einstellungen.tsx
@@ -6,11 +6,12 @@
import { useEffect, useRef, useState } from "react"
-import { ActionButton, Feld, Popup, Seg, Switch, Tile } from "@audi-dash/ui"
+import { ActionButton, Feld, Seg, Sheet, Switch, Tile } from "@audi-dash/ui"
import { DIENST_DOMAIN, zugangLesen, zugangVerwerfen } from "../api"
import type { Profil } from "../api"
import { eigeneVersion } from "../daten/appVersion"
+import { Datumsfeld } from "./Datumsfeld"
import { useDaten } from "../daten/DatenKontext"
import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota"
import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter"
@@ -175,9 +176,20 @@ export function Einstellungen({
// Bei Winterraedern zeigt die Seitenansicht das Winterfoto - wie bildInfo()
// im Panel, das hier dieselbe Vorschau liefert.
const gewaehltesBild =
- werte.startbild === "seitenansicht.webp" && fahrzeug.reifen.aktiv === "Winter"
+ /* Verglichen wird der NAME der Ansicht, nicht der Dateiname.
+
+ Das Panel speichert in `einstellungen.uebersichtsbild` seit jeher den
+ Namen ("Seitenansicht"), die App verglich hier gegen den Dateinamen
+ ("seitenansicht.webp") - der Vergleich konnte also nie zutreffen, die
+ Auswahl fiel immer auf den ersten Eintrag zurueck, und was die App
+ schrieb, erkannte das Panel seinerseits nicht wieder. Nachgesehen im
+ echten Profil der Testinstanz (2026-08-31): dort steht
+ 'Seitenansicht'. Kanonisch ist der Name - die aeltere, bereits
+ gespeicherte Form, dieselbe Entscheidung wie beim Wartungsplan. Die
+ Namen beider Kataloge stimmen ueberein. */
+ werte.startbild === "Seitenansicht" && fahrzeug.reifen.aktiv === "Winter"
? { datei: SEITE_WINTER.datei, beschreibung: SEITE_WINTER.beschreibung }
- : (BILDPLAETZE.find((b) => b.datei === werte.startbild) ?? BILDPLAETZE[0]!)
+ : (BILDPLAETZE.find((b) => b.name === werte.startbild) ?? BILDPLAETZE[0]!)
const aendern = (teil: Partial) =>
setzeEntwurf({ ...werte, ...teil })
@@ -354,14 +366,23 @@ export function Einstellungen({
Bild der Übersicht
+ {/* Sofort gespeichert, nicht ueber die Speicherleiste am Seitenende.
+ Das Umschalten der Ansicht ist keine Aenderung, die man sammelt
+ und spaeter bestaetigt - man will das andere Foto sehen. Das
+ Panel macht es genauso (data-startbild ruft direkt
+ profilSpeichern()); in der App tauchte stattdessen unten die
+ Leiste "Aenderungen speichern" auf (gemeldet 2026-08-31). */}
aendern({ startbild: e.target.value })}
+ onChange={(e) => {
+ const startbild = e.target.value
+ void profilSpeichern({ einstellungen: { ...werte, startbild } })
+ }}
>
{BILDPLAETZE.map((platz) => (
-
+
{platz.name}
))}
@@ -391,7 +412,7 @@ export function Einstellungen({
{
e.currentTarget.style.display = "none"
}}
@@ -442,23 +463,20 @@ export function Einstellungen({
)}
- setzeSdAbfrage(false)}
- variant="form"
- anchor="center"
+ title="SmartDeal"
+ description="Bis wann gilt der Rabatt?"
>
- SmartDeal
- setzeSdEntwurf(e.target.value)}
- />
+
-
setzeSdAbfrage(false)}>Abbrechen
Bestätigen
+
setzeSdAbfrage(false)}>Abbrechen
-
+
{/* „Nacht beginnt"/„Nacht endet" sind entfallen: das Panel fuehrt sie
in den Einstellungen nicht, die Nachtgrenze steht dort nur als
diff --git a/companion-app/src/screens/FahrtDetail.tsx b/companion-app/src/screens/FahrtDetail.tsx
index 163465b..ac5c481 100644
--- a/companion-app/src/screens/FahrtDetail.tsx
+++ b/companion-app/src/screens/FahrtDetail.tsx
@@ -9,7 +9,7 @@
* ist, statt eine glaubwürdig aussehende Erfindung zu zeigen.
*/
-import { useState } from "react"
+import { useEffect, useState } from "react"
import { ActionButton, Feld, Fig, Pill, Tile } from "@audi-dash/ui"
@@ -30,9 +30,24 @@ function alsDatetimeLocal(iso: string): string {
}
export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (name: SeitenName) => void }) {
- const { fahrten, api } = useDaten()
+ const { fahrten, api, jetztAktualisieren } = useDaten()
const fahrt = fahrten.find((f) => f.trip_id === id)
const [bearbeitenOffen, setzeBearbeitenOffen] = useState(false)
+ // Vorgriff auf die Antwort des Backends: der Dienstaufruf laeuft ueber die
+ // Warteschlange und der neue Stand kommt erst mit dem naechsten Push zurueck.
+ // Ohne diesen Zwischenwert bliebe die Pille nach dem Tippen sekundenlang auf
+ // dem alten Text stehen und der Tipp saehe aus, als waere er verloren
+ // gegangen. Das Panel macht dasselbe (dort direkt auf TRIPS, gefolgt von
+ // render()).
+ const [artVorgriff, setzeArtVorgriff] = useState<"privat" | "arbeitsweg" | null>(null)
+
+ // Faellt der Vorgriff mit dem echten Wert zusammen, wird er ueberfluessig.
+ // Bleibt er stehen, wuerde eine spaetere Aenderung von aussen (Panel,
+ // zweites Geraet) von einem laengst erledigten Tipp ueberdeckt.
+ const echteArt = fahrt?.art
+ useEffect(() => {
+ if (artVorgriff && echteArt === artVorgriff) setzeArtVorgriff(null)
+ }, [echteArt, artVorgriff])
if (!fahrt) {
return (
@@ -43,6 +58,13 @@ export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (n
)
}
+ const art = artVorgriff ?? fahrt.art
+ const artUmschalten = () => {
+ const neu = art === "arbeitsweg" ? "privat" : "arbeitsweg"
+ setzeArtVorgriff(neu)
+ void api.fahrtAktualisieren(fahrt.trip_id, { art: neu }).then(() => jetztAktualisieren())
+ }
+
const loeschen = () => {
if (!fahrt || !bestaetigen("Fahrt löschen?")) return
void api.fahrtLoeschen(fahrt.trip_id)
@@ -90,7 +112,12 @@ export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (n
-
{fahrt.art === "arbeitsweg" ? "Arbeitsweg" : "Privat"}
+ {/* Knopf, nicht Anzeige: im Panel schaltet dieselbe Pille seit jeher
+ zwischen Privat und Arbeitsweg um (data-art, fahrt_aktualisieren).
+ In der App war sie bis 2026-08-31 ein totes
. */}
+
+ {art === "arbeitsweg" ? "Arbeitsweg" : "Privat"}
+
diff --git a/companion-app/src/screens/Fahrzeugdaten.tsx b/companion-app/src/screens/Fahrzeugdaten.tsx
index 337bec9..f209370 100644
--- a/companion-app/src/screens/Fahrzeugdaten.tsx
+++ b/companion-app/src/screens/Fahrzeugdaten.tsx
@@ -69,7 +69,7 @@ export function Fahrzeugdaten() {
label="Ausführung"
wert={einstellungen.ausfuehrung || "noch offen"}
/>
-
+
- Daten importieren aus Home Assistant
+ /* Blatt statt schwebender Karte - Apples Vorgabe fuer eine vom Nutzer
+ angestossene Eingabe auf dem Telefon. */
+
{fertig ? (
<>
@@ -202,6 +208,6 @@ export function HistorienImport({
>
)}
-
+
)
}
diff --git a/companion-app/src/screens/Karte.tsx b/companion-app/src/screens/Karte.tsx
index e1c58c2..1680e20 100644
--- a/companion-app/src/screens/Karte.tsx
+++ b/companion-app/src/screens/Karte.tsx
@@ -26,6 +26,81 @@ export interface Punkt {
const KACHEL_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
const KACHEL_ZUSATZ = "© OpenStreetMap contributors"
+const ROT = "#F50537"
+
+/* Fahrtrichtung auf der Streckenlinie. Deckungsgleich mit spurPfeile() im
+ Panel - dort steht die ausfuehrliche Begruendung.
+
+ Kurz: bis 2026-08-31 lag auf der Linie je ein runder Punkt pro
+ GPS-Stuetzpunkt. Die sagten nichts ueber die Richtung. Die Form ist der
+ Navigationspfeil und nicht ein gleichschenkliges Dreieck: das Dreieck
+ sieht von beiden Seiten gleich aus, die Kerbe im Fuss macht Kopf und Ende
+ unterscheidbar. Zwei Entscheidungen,
+ an Mustern nachgemessen: Abstand nach Strecke statt nach Stuetzpunkten (die
+ Punktdichte haengt an der Geschwindigkeit, Pfeile daran wuerden dort klumpen,
+ wo am wenigsten passiert ist), und weisser Rand (sonst verschwimmt das rote
+ Dreieck mit der roten Linie). Ein Versatz quer zur Fahrtrichtung war erprobt
+ und trennt Hin- und Rueckweg - aber nur bei Punkt fuer Punkt deckungsgleichen
+ Spuren, die es in echten Aufzeichnungen nicht gibt. */
+function spurMeter(a: [number, number], b: [number, number]): number {
+ const R = 6371000,
+ r = Math.PI / 180
+ const dLat = (b[0] - a[0]) * r,
+ dLon = (b[1] - a[1]) * r
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(a[0] * r) * Math.cos(b[0] * r) * Math.sin(dLon / 2) ** 2
+ return R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h))
+}
+
+/** Winkel im Uhrzeigersinn ab Norden - genau das, was CSS rotate() erwartet.
+ Die Laengendifferenz wird mit cos(Breite) gestaucht, sonst zeigten die
+ Pfeile in unseren Breiten rund 30 Grad daneben. */
+function spurWinkel(a: [number, number], b: [number, number]): number {
+ const dx = (b[1] - a[1]) * Math.cos((((a[0] + b[0]) / 2) * Math.PI) / 180)
+ return (Math.atan2(dx, b[0] - a[0]) * 180) / Math.PI
+}
+
+function spurPfeile(
+ L: typeof import("leaflet"),
+ karte: import("leaflet").Map,
+ spur: [number, number][],
+): void {
+ if (spur.length < 2) return
+ const laengen: number[] = []
+ let gesamt = 0
+ for (let i = 1; i < spur.length; i++) {
+ const d = spurMeter(spur[i - 1]!, spur[i]!)
+ laengen.push(d)
+ gesamt += d
+ }
+ if (!gesamt) return
+ // Rund alle 350 m, aber nie weniger als 4 und nie mehr als 14: auf einer
+ // 200-km-Fahrt waeren 570 Pfeile eine zweite Linie, auf 300 m gar keiner.
+ const anzahl = Math.max(4, Math.min(14, Math.round(gesamt / 350)))
+ const schritt = gesamt / (anzahl + 1)
+ let ziel = schritt,
+ gelaufen = 0
+ for (let i = 1; i < spur.length && ziel < gesamt; i++) {
+ const d = laengen[i - 1]!
+ while (ziel <= gelaufen + d && ziel < gesamt) {
+ const f = d ? (ziel - gelaufen) / d : 0
+ const lat = spur[i - 1]![0] + (spur[i]![0] - spur[i - 1]![0]) * f
+ const lon = spur[i - 1]![1] + (spur[i]![1] - spur[i - 1]![1]) * f
+ const w = spurWinkel(spur[i - 1]!, spur[i]!).toFixed(1)
+ const html =
+ ``
+ L.marker([lat, lon], {
+ icon: L.divIcon({ className: "dm-spur-pfeil", html, iconSize: [13, 13], iconAnchor: [6.5, 6.5] }),
+ interactive: false,
+ }).addTo(karte)
+ ziel += schritt
+ }
+ gelaufen += d
+ }
+}
+
export function Karte({
start,
ziel,
@@ -61,18 +136,19 @@ export function Karte({
if (ziel) {
punkte.push([ziel.lat, ziel.lon])
- L.circleMarker([ziel.lat, ziel.lon], { radius: 6, color: "#F50537", weight: 2, fillColor: "#F50537", fillOpacity: 1 })
+ L.circleMarker([ziel.lat, ziel.lon], { radius: 6, color: ROT, weight: 2, fillColor: ROT, fillOpacity: 1 })
.bindTooltip("Ziel")
.addTo(karte)
// Echte Route, wenn vorhanden - sonst eine Verbindungslinie NUR zwischen
// den beiden echten Punkten, kein erfundener Verlauf dazwischen.
const linie = route && route.length >= 2 ? route : punkte
- L.polyline(linie, { color: "#F50537", weight: 2.5, opacity: 0.9, dashArray: route && route.length >= 2 ? undefined : "4 6" }).addTo(karte)
+ L.polyline(linie, { color: ROT, weight: 2.5, opacity: 0.9, dashArray: route && route.length >= 2 ? undefined : "4 6" }).addTo(karte)
if (route && route.length >= 2) {
- // Kleine Zwischenpunkte entlang der echten Strecke, wie im Panel.
- route.slice(1, -1).forEach((punkt) => {
- L.circleMarker(punkt, { radius: 2.5, color: "#F50537", weight: 0, fillColor: "#F50537", fillOpacity: 0.85 }).addTo(karte!)
- })
+ // Richtungspfeile statt der frueheren Punkte, wie im Panel. Nur auf
+ // einer echten Aufzeichnung: die gestrichelte Ersatzlinie zwischen
+ // Start und Ziel ist keine gefahrene Strecke, und ein Pfeil darauf
+ // behauptete eine Fahrtrichtung, die niemand gemessen hat.
+ spurPfeile(L, karte, route)
punkte.push(...route)
}
}
diff --git a/companion-app/src/screens/Reifen.tsx b/companion-app/src/screens/Reifen.tsx
index 6d582c1..5d8e63e 100644
--- a/companion-app/src/screens/Reifen.tsx
+++ b/companion-app/src/screens/Reifen.tsx
@@ -17,6 +17,7 @@ import type { Reifensatz, ReifenArchivEintrag } from "../daten/profilAdapter"
import { datum, de, deOderStrich } from "../format"
import { SymbolEdit, SymbolPaket, SymbolZahnradVoll } from "../symbole"
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
+import { Datumsfeld } from "./Datumsfeld"
import { Bild } from "./Bild"
import { kalenderDateiLaden } from "./kalender"
@@ -60,7 +61,7 @@ export function Reifen() {
},
})
const ziel = aktiv === "Sommer" ? "Winter" : "Sommer"
- kalenderDateiLaden(
+ await kalenderDateiLaden(
"Reifenwechsel",
wechselDatumAnzeige,
`Wechsel auf ${ziel}reifen`,
@@ -301,12 +302,7 @@ export function Reifen() {
Wechseltermin
- setzeWechselDatum(e.target.value)}
- />
+
- setzeForm({ ...form, ersetzt_am: e.target.value })}
+ setzeForm({ ...form, ersetzt_am: w })}
/>
diff --git a/companion-app/src/screens/Service.tsx b/companion-app/src/screens/Service.tsx
index eef21b9..3607221 100644
--- a/companion-app/src/screens/Service.tsx
+++ b/companion-app/src/screens/Service.tsx
@@ -30,6 +30,7 @@ import {
Zahnradknopf,
bestaetigen,
} from "./bausteine"
+import { Datumsfeld } from "./Datumsfeld"
import { kalenderDateiLaden } from "./kalender"
import { vcardParsen } from "./vcard"
@@ -295,15 +296,10 @@ function TerminVereinbaren() {
- void merken(e.target.value)}
- />
+ void merken(w)} />
kalenderDateiLaden(art, datumWert || isoTag(), `${art} in der Werkstatt`)}
+ onClick={() => void kalenderDateiLaden(art, datumWert || isoTag(), `${art} in der Werkstatt`)}
>
In den Kalender übernehmen
@@ -480,12 +476,7 @@ export function Servicebuch({
Eintrag
- setze("datum", e.target.value)}
- />
+ setze("datum", w)} />
void,
setzeFehler: (f: string | null) => void,
-): void {
+): () => void {
if (!navigator.geolocation) {
setzeFehler("GPS offline")
- return
+ return () => {}
}
- navigator.geolocation.getCurrentPosition(
+ const kennung = navigator.geolocation.watchPosition(
(pos) => {
setzeFehler(null)
setzePosition({ lat: pos.coords.latitude, lon: pos.coords.longitude })
@@ -160,8 +177,11 @@ function eigenePositionErfassen(
// sonst "GPS offline" (deckungsgleich mit dem Panel).
setzeFehler(err.code === err.PERMISSION_DENIED ? "GPS-Freigabe fehlt" : "GPS offline")
},
- { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 },
+ // maximumAge klein: eine zwischengespeicherte Position aus der letzten
+ // halben Minute waere hier das Gegenteil von "live".
+ { enableHighAccuracy: true, timeout: 15000, maximumAge: 2000 },
)
+ return () => navigator.geolocation.clearWatch(kennung)
}
/** Kleine, nicht interaktive Kartenvorschau auf der Übersicht — Vorlage:
@@ -227,7 +247,14 @@ export function Standort() {
const [satellit, setzeSatellit] = useState(false)
const [eigenePosition, setzeEigenePosition] = useState(null)
const [positionsFehler, setzePositionsFehler] = useState(null)
+ // Zaehler statt Schalter: jeder Druck auf den GPS-Knopf ohne bekannte
+ // Position soll die Ortung neu anstossen, nicht nur der erste. Ein einmal
+ // gescheiterter watchPosition-Auftrag meldet sich nicht von selbst wieder -
+ // etwa nachdem die Freigabe in den iOS-Einstellungen nachgereicht wurde.
+ const [ortungsLauf, setzeOrtungsLauf] = useState(0)
const [adresse, setzeAdresse] = useState(null)
+
+ useEffect(() => standortVerfolgen(setzeEigenePosition, setzePositionsFehler), [ortungsLauf])
const [blattOffen, setzeBlattOffen] = useState(false)
const blattRef = useRef(null)
// Zug am Griff, deckungsgleich mit standortMenuVerdrahten() im Panel: das
@@ -299,7 +326,6 @@ export function Standort() {
if (fahrzeugPos) karte.setView([fahrzeugPos.lat, fahrzeugPos.lon], 15)
else karte.setView([51.16, 10.45], 5)
setTimeout(() => karte.invalidateSize(), 60)
- eigenePositionErfassen(setzeEigenePosition, setzePositionsFehler)
})
return () => {
abgebrochen = true
@@ -457,7 +483,7 @@ export function Standort() {
type="button"
onClick={() => {
if (!eigenePosition) {
- eigenePositionErfassen(setzeEigenePosition, setzePositionsFehler)
+ setzeOrtungsLauf((l) => l + 1)
return
}
kartenRef.current?.flyTo([eigenePosition.lat, eigenePosition.lon], Math.max(kartenRef.current.getZoom(), 15))
diff --git a/companion-app/src/screens/TankDetail.tsx b/companion-app/src/screens/TankDetail.tsx
index b76a300..677c9f8 100644
--- a/companion-app/src/screens/TankDetail.tsx
+++ b/companion-app/src/screens/TankDetail.tsx
@@ -7,7 +7,7 @@
* Genau dieses Muster hatte auch das alte Panel.
*/
-import { useEffect, useRef, useState } from "react"
+import { useEffect, useState } from "react"
import { ActionButton, Feld, Fig, Tile } from "@audi-dash/ui"
@@ -18,6 +18,7 @@ import { datum, de, deOderStrich, eur, uhrzeit } from "../format"
import type { SeitenName } from "../navigation"
import { KRAFTSTOFFSORTEN, Leerzustand, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { markenlogoUrl } from "./bilder"
+import { BelegPopup } from "./BelegPopup"
import { TankstellenKarte } from "./TankstellenKarte"
interface BelegErgebnis {
@@ -316,7 +317,9 @@ function BelegKnopf({
belegDatei: string | null
api: ReturnType["api"]
}) {
- const dateiwahl = useRef(null)
+ // Wie in Tanken.tsx: erst das Beleg-Fenster, dann der gewaehlte Weg. Am
+ // Telefon ist das Einfuegen aus der Zwischenablage der Hauptweg.
+ const [belegFensterOffen, setzeBelegFensterOffen] = useState(false)
const [laeuft, setzeLaeuft] = useState(false)
const [meldung, setzeMeldung] = useState<{ art: "ok" | "fehler"; text: string } | null>(null)
@@ -384,15 +387,12 @@ function BelegKnopf({
return (
<>
- {
- const datei = e.target.files?.[0]
- if (datei) void hochladen(datei)
- e.target.value = ""
+ setzeBelegFensterOffen(false)}
+ beiDatei={(datei) => {
+ setzeBelegFensterOffen(false)
+ void hochladen(datei)
}}
/>
{meldung && (
@@ -403,7 +403,7 @@ function BelegKnopf({
{meldung.text}
)}
- dateiwahl.current?.click()} disabled={laeuft || wartet}>
+ setzeBelegFensterOffen(true)} disabled={laeuft || wartet}>
{laeuft
? "Lade hoch …"
: wartet
diff --git a/companion-app/src/screens/Tanken.tsx b/companion-app/src/screens/Tanken.tsx
index f252265..6a2a256 100644
--- a/companion-app/src/screens/Tanken.tsx
+++ b/companion-app/src/screens/Tanken.tsx
@@ -6,7 +6,7 @@
* heben wie eine große.
*/
-import { useRef, useState } from "react"
+import { useEffect, useState, useSyncExternalStore } from "react"
import { Accordion, ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
@@ -15,7 +15,13 @@ import { useDaten } from "../daten/DatenKontext"
import { nachJahrUndMonat } from "../daten/statistik"
import { datum, de, eur, schnittpreis, summe } from "../format"
import type { SeitenName } from "../navigation"
+import {
+ geteiltenBelegHorchen,
+ geteiltenBelegSetzen,
+ geteiltenBelegStand,
+} from "../daten/geteilterBeleg"
import { Blattzeile, KRAFTSTOFFSORTEN, Leerzustand } from "./bausteine"
+import { BelegPopup } from "./BelegPopup"
import { markenlogoUrl } from "./bilder"
import { Zeilenmenue } from "./Zeilenmenue"
@@ -23,6 +29,15 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
const { tankvorgaenge, fahrzeug, einstellungen, api } = useDaten()
const [formularOffen, setzeFormularOffen] = useState(false)
+ /* Kommt ein Beleg ueber das Teilen-Blatt herein (App.tsx holt ihn ab und
+ springt hierher), oeffnet sich das Formular von selbst - das Formular
+ selbst nimmt den Beleg dann auf. Ohne das laege der Beleg bereit und
+ niemand saehe, dass etwas passiert ist. */
+ const geteilt = useSyncExternalStore(geteiltenBelegHorchen, geteiltenBelegStand)
+ useEffect(() => {
+ if (geteilt) setzeFormularOffen(true)
+ }, [geteilt])
+
const gruppen = nachJahrUndMonat(tankvorgaenge, (t) => t.ts)
const smartdealAktiv = fahrzeug?.smartdeal.aktiv === true
@@ -59,7 +74,8 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
{
e.currentTarget.style.display = "none"
}}
@@ -172,7 +188,26 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
einem bereits bestehenden Tankvorgang nachtragen. */
const [beleg, setzeBeleg] = useState(null)
const [belegLaeuft, setzeBelegLaeuft] = useState(false)
- const belegwahl = useRef(null)
+ // Der Knopf oeffnet nicht mehr direkt die Dateiauswahl, sondern das
+ // Beleg-Fenster: auf dem iPhone ist Einfuegen aus der Zwischenablage der
+ // Hauptweg (PDF aus der Mail), die Dateiauswahl landet dort im
+ // Dateispeicher. Genau so macht es das Panel seit laengerem.
+ const [belegFensterOffen, setzeBelegFensterOffen] = useState(false)
+
+ /* Ein ueber das Teilen-Blatt hereingereichter Beleg laeuft durch genau
+ denselben belegLesen()-Weg wie eine von Hand gewaehlte Datei - er wird
+ also gelesen, ausgewertet und fuellt das Formular vor. Danach aus dem
+ geteilten Speicher nehmen, sonst laeuft er beim naechsten Oeffnen des
+ Formulars ein zweites Mal durch. */
+ const geteilt = useSyncExternalStore(geteiltenBelegHorchen, geteiltenBelegStand)
+ useEffect(() => {
+ if (!geteilt) return
+ geteiltenBelegSetzen(null)
+ void belegLesen(geteilt)
+ // belegLesen haengt an api/fahrzeug und ist bei jedem Rendern neu;
+ // ausschlaggebend ist allein, dass ein Beleg hereingekommen ist.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [geteilt])
const smartdealAktiv = fahrzeug?.smartdeal.aktiv === true
@@ -348,18 +383,15 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
Felder und die Knöpfe. */}
{/* Drei Knöpfe wie in vFuelFormular(): erst den Beleg lesen lassen, dann
mit oder ohne ihn speichern. */}
- {
- const datei = e.target.files?.[0]
- if (datei) void belegLesen(datei)
- e.target.value = ""
+ setzeBelegFensterOffen(false)}
+ beiDatei={(datei) => {
+ setzeBelegFensterOffen(false)
+ void belegLesen(datei)
}}
/>
- belegwahl.current?.click()} disabled={belegLaeuft || laeuft}>
+ setzeBelegFensterOffen(true)} disabled={belegLaeuft || laeuft}>
{belegLaeuft ? "Beleg wird gelesen …" : beleg ? "Beleg ersetzen" : "Beleg hochladen"}
void speichern(true)} disabled={laeuft}>
diff --git a/companion-app/src/screens/Uebersicht.tsx b/companion-app/src/screens/Uebersicht.tsx
index 62a6f69..664c655 100644
--- a/companion-app/src/screens/Uebersicht.tsx
+++ b/companion-app/src/screens/Uebersicht.tsx
@@ -42,7 +42,11 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
// Bodenschatten unter dem Fahrzeug, wie szeneKlasse() im Panel: abschaltbar,
// nicht bei Innenraumaufnahmen, blaustichig bei montierten Winterrädern.
const winterraeder = fahrzeug.reifen.aktiv === "Winter"
- const platz = BILDPLAETZE.find((p) => p.datei === einstellungen.startbild) ?? BILDPLAETZE[0]!
+ /* Der gespeicherte Wert ist der NAME der Ansicht, nicht der Dateiname -
+ siehe die Begruendung in Einstellungen.tsx. Der Vergleich gegen datei
+ traf nie zu, es wurde also immer der erste Platz gezeigt, egal was in
+ den Einstellungen gewaehlt war. */
+ const platz = BILDPLAETZE.find((p) => p.name === einstellungen.startbild) ?? BILDPLAETZE[0]!
// Bei Winterrädern zeigt die Seitenansicht das Winterfoto, wie bildInfo()
// im Panel — das fehlte hier ganz.
const startbild = bildInfo(platz, winterraeder)
diff --git a/companion-app/src/screens/Versicherung.tsx b/companion-app/src/screens/Versicherung.tsx
index 8999a86..cba93e3 100644
--- a/companion-app/src/screens/Versicherung.tsx
+++ b/companion-app/src/screens/Versicherung.tsx
@@ -15,6 +15,7 @@ import { useDaten } from "../daten/DatenKontext"
import { datum, de, eur } from "../format"
import type { SeitenName } from "../navigation"
import { Leerzustand, Wertzeile, Werteliste, Zahnradknopf } from "./bausteine"
+import { Datumsfeld } from "./Datumsfeld"
interface Beitragsposten {
bez?: string
@@ -209,12 +210,7 @@ export function Beitrag() {
Beitrag anpassen
- setzeGueltigAb(e.target.value)}
- />
+
- setzeEntwurf((alt) => ({ ...alt, faellig: e.target.value }))}
+ setzeEntwurf((alt) => ({ ...alt, faellig: w }))}
/>
diff --git a/companion-app/src/screens/bausteine.tsx b/companion-app/src/screens/bausteine.tsx
index 4234314..e3cfcd5 100644
--- a/companion-app/src/screens/bausteine.tsx
+++ b/companion-app/src/screens/bausteine.tsx
@@ -5,12 +5,13 @@
import { useRef, useState, type ReactNode } from "react"
-import { ActionButton, Tile } from "@audi-dash/ui"
+import { ActionButton, ActionSheet, Tile } from "@audi-dash/ui"
import type { SicherheitsPunkt } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { SymbolZahnradVoll } from "../symbole"
import { Bild } from "./Bild"
+import { bildVersionErneuern } from "./bilder"
/** Kraftstoffsorten zur Auswahl - dieselbe feste Liste wie `KRAFTSTOFFSORTEN`
im Panel, geteilt zwischen Tanken.tsx (Neuanlegen) und TankDetail.tsx
@@ -83,18 +84,17 @@ export function BildMitMenue({
beschreibung,
name,
groesse,
- menueLage,
dateiZeigen,
}: {
datei: string
beschreibung: string
name: string
groesse?: "default" | "mini" | "round"
- /** "ansicht" = unter dem Bild zentriert, "rad" = links oben (wie im Panel). */
- menueLage?: "ansicht" | "rad"
+ /* menueLage ist am 2026-08-31 entfallen: ein Aktionsblatt kommt immer von
+ der unteren Kante, es gibt keine Lage mehr zu waehlen. */
dateiZeigen?: boolean
}) {
- const { api } = useDaten()
+ const { api, jetztAktualisieren } = useDaten()
const [offen, setzeOffen] = useState(false)
const datei_ = useRef(null)
@@ -106,7 +106,13 @@ export function BildMitMenue({
leser.readAsDataURL(gewaehlt)
})
await api.bildHochladen(datei, base64)
+ /* Ohne diese beiden Zeilen bliebe das alte Foto stehen: die Adresse ist
+ unveraendert, und Home Assistant liefert /local/ mit 31 Tagen
+ Cache-Vorgabe aus. Erneuern setzt den Bildstand, das Nachladen bringt
+ die Oberflaeche dazu, ihn auch zu benutzen. */
+ bildVersionErneuern()
setzeOffen(false)
+ void jetztAktualisieren()
}
return (
@@ -131,26 +137,34 @@ export function BildMitMenue({
e.target.value = ""
}}
/>
- {offen && (
- <>
- setzeOffen(false)} />
-
- datei_.current?.click()}>
- Foto ersetzen
-
- {
- setzeOffen(false)
- if (bestaetigen("Foto löschen?")) void api.bildLoeschen(datei)
- }}
- >
- Löschen
-
-
- >
- )}
+ {/* Auswahlblatt statt schwebendem Menü: zwei Wege, die der Nutzer
+ selbst angestoßen hat, plus ein abgesetztes Abbrechen - genau der
+ Zuschnitt, für den Apple das Aktionsblatt vorsieht. Das frühere
+ Menü schwebte neben dem Bild, hatte keinen erkennbaren Ausweg und
+ saß je nach Lage woanders. */}
+
setzeOffen(false)}
+ title={name}
+ actions={[
+ {
+ label: "Foto ersetzen",
+ onClick: () => datei_.current?.click(),
+ },
+ {
+ label: "Foto löschen",
+ destructive: true,
+ onClick: () => {
+ setzeOffen(false)
+ if (!bestaetigen("Foto löschen?")) return
+ void api.bildLoeschen(datei).then(() => {
+ bildVersionErneuern()
+ return jetztAktualisieren()
+ })
+ },
+ },
+ ]}
+ />
)
}
diff --git a/companion-app/src/screens/bilder.ts b/companion-app/src/screens/bilder.ts
index 71aa84e..5182db4 100644
--- a/companion-app/src/screens/bilder.ts
+++ b/companion-app/src/screens/bilder.ts
@@ -63,6 +63,32 @@ export const ERLAUBTE_DATEINAMEN: readonly string[] = [
...WEITERE_BILDER,
]
+/**
+ * Stand der Fahrzeugfotos, als Anhängsel an jede Bildadresse.
+ *
+ * Home Assistant liefert alles unter `/local/` mit
+ * `Cache-Control: public, max-age=2678400` aus — einunddreißig Tage
+ * (nachgemessen 2026-08-31). Ohne ein wechselndes Anhängsel zeigt die App
+ * deshalb einen Monat lang das Bild, das sie beim ersten Mal geholt hat:
+ * ein neu hochgeladenes Foto erscheint nicht, ein ersetztes bleibt das alte,
+ * und ein 404 von vorher bleibt ein 404. Genau das ist dem Eigentümer mit
+ * den Radfotos passiert — im Panel eingefügt, in der App unsichtbar.
+ *
+ * Das Panel hängt seit jeher `?v=${bildVersion}` an (`bildInfo()` dort) und
+ * setzt den Wert nach jedem Hochladen oder Löschen neu. Hier dasselbe: beim
+ * Start der App einmal, danach bei jeder Änderung.
+ *
+ * Der Preis ist ein erneuter Abruf je App-Start. Der ist gewollt — ein Foto,
+ * das der Nutzer nicht sieht, kostet mehr als ein Megabyte über eine
+ * Verbindung, die ohnehin gerade das ganze Fahrzeugprofil holt.
+ */
+let bildVersion = Date.now()
+
+/** Nach dem Hochladen oder Löschen eines Fotos aufrufen. */
+export function bildVersionErneuern(): void {
+ bildVersion = Date.now()
+}
+
/**
* Adresse eines Fahrzeugfotos auf dem Server. Die Bilder liegen unter
* `/local/bilder/` — anders als beim alten Panel läuft die App nicht
@@ -72,7 +98,7 @@ export function bildUrl(dateiname: string | null | undefined): string | undefine
if (!dateiname) return undefined
const basis = zugangLesenSynchron()?.basisUrl
if (!basis) return undefined
- return `${basis}/local/bilder/${dateiname}`
+ return `${basis}/local/bilder/${dateiname}?v=${bildVersion}`
}
/**
diff --git a/companion-app/src/screens/bildplatzhalter.test.tsx b/companion-app/src/screens/bildplatzhalter.test.tsx
new file mode 100644
index 0000000..97a6842
--- /dev/null
+++ b/companion-app/src/screens/bildplatzhalter.test.tsx
@@ -0,0 +1,69 @@
+/**
+ * Regression: die Fotogalerie unter „Mein Audi" behielt den Platzhalter.
+ *
+ * `ImagePlaceholder` merkte sich nur, DASS ein Bild nicht geladen werden
+ * konnte — nicht welches. In der Galerie bleibt dieselbe Komponente stehen und
+ * bekommt beim Weiterschalten nur eine neue `src`; ein einmal gesetztes
+ * „fehlgeschlagen" galt damit für alle folgenden Bilder mit. Sobald ein noch
+ * leerer Platz durchlaufen war, zeigten auch die Plätze mit echtem Foto nur
+ * noch den Platzhalter, bis ein Seitenwechsel die Komponente neu aufbaute
+ * (gemeldet vom Eigentümer, 2026-08-31).
+ *
+ * Das Panel hat diese Falle nie gehabt: `bildWeiter()` nimmt dort die Klasse
+ * `bild-fehlt` ausdrücklich wieder weg, mit genau diesem Kommentar.
+ *
+ * Warum der Test hier und nicht im Design-System liegt: dort gibt es keine
+ * Testumgebung mit DOM. Geprüft wird die Bibliothek über die App, die sie
+ * benutzt — dieselbe Stelle, an der der Fehler aufgefallen ist.
+ */
+
+import { fireEvent, render } from "@testing-library/react"
+import { describe, expect, it } from "vitest"
+
+import { ImagePlaceholder } from "@audi-dash/ui"
+
+const bild = (behaelter: HTMLElement) => behaelter.querySelector("img")
+const platzhalter = (behaelter: HTMLElement) => behaelter.querySelector(".ads-platzhalter")
+
+describe("ImagePlaceholder", () => {
+ it("zeigt den Platzhalter, wenn das Bild nicht lädt", () => {
+ const { container } = render( )
+ expect(bild(container)).not.toBeNull()
+
+ fireEvent.error(bild(container)!)
+
+ expect(bild(container)).toBeNull()
+ expect(platzhalter(container)).not.toBeNull()
+ })
+
+ it("zeigt das nächste Bild wieder an, nachdem das vorherige fehlgeschlagen ist", () => {
+ const { container, rerender } = render( )
+ fireEvent.error(bild(container)!)
+ expect(platzhalter(container)).not.toBeNull()
+
+ // Weiterschalten in der Galerie: dieselbe Komponente, neue Adresse.
+ rerender( )
+
+ expect(bild(container)).not.toBeNull()
+ expect(bild(container)!.getAttribute("src")).toBe("/foto.webp")
+ expect(platzhalter(container)).toBeNull()
+ })
+
+ it("merkt sich den Fehlschlag pro Adresse, nicht global", () => {
+ const { container, rerender } = render( )
+ fireEvent.error(bild(container)!)
+
+ rerender( )
+ expect(bild(container)).not.toBeNull()
+
+ // Zurück auf den bekannt leeren Platz: dort gehört der Platzhalter hin.
+ rerender( )
+ expect(platzhalter(container)).not.toBeNull()
+ })
+
+ it("zeigt den Platzhalter ohne Adresse", () => {
+ const { container } = render( )
+ expect(bild(container)).toBeNull()
+ expect(platzhalter(container)).not.toBeNull()
+ })
+})
diff --git a/companion-app/src/screens/kalender.ts b/companion-app/src/screens/kalender.ts
index c05865b..a562072 100644
--- a/companion-app/src/screens/kalender.ts
+++ b/companion-app/src/screens/kalender.ts
@@ -1,10 +1,26 @@
/**
* Termine als Kalenderdatei. Portiert aus `ics()` im alten Panel.
*
- * Erzeugt eine .ics-Datei im Browser, ohne Server — sie landet als Download
- * bzw. auf dem iPhone direkt im Kalender. Mit Erinnerung zwei Tage vorher.
+ * Erzeugt eine .ics-Datei ohne Server, mit Erinnerung zwei Tage vorher.
+ *
+ * ZWEI WEGE, WEIL EIN DOWNLOAD IN DER APP NICHT ANKOMMT
+ * -----------------------------------------------------
+ * Im Browser und im HA-Panel genügt ein `` mit einer
+ * Blob-Adresse. In der nativen Hülle nicht: **WKWebView ignoriert das
+ * `download`-Attribut**, der Klick läuft ins Leere und es passiert sichtbar
+ * nichts — genau so gemeldet („Wechseltermin in Kalender übernehmen klappt
+ * nicht", 2026-08-31).
+ *
+ * Auf dem Gerät geht die Datei deshalb ins temporäre Verzeichnis und von
+ * dort ins iOS-Teilen-Blatt, aus dem „Zu Kalender hinzufügen" kommt. Das ist
+ * auch der ehrlichere Ablauf: man sieht, wohin der Termin geht, statt dass
+ * eine Datei stillschweigend irgendwo landet.
*/
+import { Capacitor } from "@capacitor/core"
+import { Directory, Encoding, Filesystem } from "@capacitor/filesystem"
+import { Share } from "@capacitor/share"
+
function zeilenUmbruch(text: string): string {
return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n")
}
@@ -50,13 +66,37 @@ function hash(text: string): number {
return wert
}
-export function kalenderDateiLaden(titel: string, isoTag: string, beschreibung = ""): void {
+function dateiname(titel: string): string {
+ return `${titel.replace(/[^wäöüÄÖÜß-]+/g, "-").toLowerCase()}.ics`
+}
+
+export async function kalenderDateiLaden(
+ titel: string,
+ isoTag: string,
+ beschreibung = "",
+): Promise {
const inhalt = kalenderDatei(titel, isoTag, beschreibung)
+ const name = dateiname(titel)
+
+ if (Capacitor.isNativePlatform()) {
+ /* Cache statt Documents: der Termin ist im Kalender aufgehoben, sobald er
+ dort ist - die Zwischendatei muss nicht überleben, und iOS darf den
+ Cache jederzeit räumen. */
+ const geschrieben = await Filesystem.writeFile({
+ path: name,
+ data: inhalt,
+ directory: Directory.Cache,
+ encoding: Encoding.UTF8,
+ })
+ await Share.share({ title: titel, url: geschrieben.uri })
+ return
+ }
+
const blob = new Blob([inhalt], { type: "text/calendar;charset=utf-8" })
const url = URL.createObjectURL(blob)
const verweis = document.createElement("a")
verweis.href = url
- verweis.download = `${titel.replace(/[^\wäöüÄÖÜß-]+/g, "-").toLowerCase()}.ics`
+ verweis.download = name
document.body.appendChild(verweis)
verweis.click()
verweis.remove()
diff --git a/companion-app/src/stile/rahmen.css b/companion-app/src/stile/rahmen.css
index a35e8d4..574470b 100644
--- a/companion-app/src/stile/rahmen.css
+++ b/companion-app/src/stile/rahmen.css
@@ -114,6 +114,18 @@
color: var(--fg);
}
+/* Spiegelbild des Zurueck-Pfeils auf der rechten Seite, damit der
+ zentrierte Titel wirklich in der Mitte der Kopfzeile steht und nicht in
+ der Mitte des Rests. Gleiche Masse, gleicher negativer Aussenabstand -
+ die 34px, die links vom Pfeil samt Abstand belegt werden, stehen damit
+ auch rechts. */
+.dm-kopf__gegengewicht {
+ flex: none;
+ width: 34px;
+ height: 34px;
+ margin-right: -8px;
+}
+
.dm-kopf__aktion {
min-width: 44px;
height: 44px;
diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css
index 499f6fb..04f4cb9 100644
--- a/companion-app/src/stile/screens.css
+++ b/companion-app/src/stile/screens.css
@@ -275,8 +275,12 @@ input[type="date"].dm-eingabe {
left: 0;
right: 0;
bottom: 0;
- height: 42%;
- background: linear-gradient(to bottom, transparent, var(--canvas) 78%);
+ /* 26/96 statt 42/78: der Verlauf soll die harte Bildkante ausblenden,
+ nicht das Fahrzeug. Mit 42% Hoehe und Vollton ab 78% war das untere
+ Drittel des Fotos praktisch einfarbig - Raeder und Schweller lagen
+ darunter (gemeldet vom Eigentuemer, 2026-08-31). */
+ height: 26%;
+ background: linear-gradient(to bottom, transparent, var(--canvas) 96%);
pointer-events: none;
z-index: 1;
}
@@ -1675,21 +1679,24 @@ button.dm-listenzeile {
gezaehlt, das Blatt war auf dem Geraet rund 16px hoeher als noetig und
schob sich entsprechend weiter hoch. Am Rechner faellt das nicht auf,
dort ist der Einzug 0. */
- padding: 0 20px 18px;
+ padding: 0 20px 34px;
transform: translateY(calc(100% - var(--dm-standort-peek)));
transition: transform 0.28s cubic-bezier(0.22, 0.61, 0.36, 1);
}
-/* Aufgezogen rastet das Blatt auf der mittleren Hoehe ein, nicht auf seiner
- Inhaltshoehe. Apple beschreibt genau das als "detents": Hoehen, auf denen
- ein Blatt natuerlich zur Ruhe kommt - large ist das voll ausgefahrene
- Blatt, medium etwa die Haelfte davon. Vorher war die Rasthoehe schlicht
- die Hoehe des Inhalts, dadurch schlossen die Knoepfe unten buendig mit der
- Menueleiste ab. Der Ueberschuss liegt unter den Knoepfen und haelt sie von
- der Leiste frei. Die eingeklappte Hoehe bleibt unberuehrt: die Verschiebung
- rechnet mit 100% der jeweiligen Blatthoehe minus dem Guckwert. */
+/* Aufgezogen bleibt unter den Knoepfen ein fester Abstand stehen, statt dass
+ sie buendig mit der Menueleiste abschliessen.
+
+ Vorher stand hier min-height:50% - Apples "medium detent". Gemessen ergab
+ das 89px (Panel) bzw. 76px (App) Luft unter dem letzten Knopf, und das war
+ sichtbar zu viel: das Blatt zog ueber die Haelfte des Bildschirms auf,
+ obwohl sein Inhalt dort laengst zu Ende war. Eine Raste ist nur dann
+ richtig, wenn zwischen ihr und der Inhaltshoehe kein grosses Loch klafft.
+
+ Jetzt bestimmt der Inhalt die Hoehe, und die 34px Fussabstand halten die
+ Knoepfe von der Leiste frei - hergeleitet aus der Beanstandung, nicht
+ geraten: 18px waren zu wenig, 76-89px zu viel. */
.dm-standortmenu--offen {
transform: translateY(0);
- min-height: 50%;
}
.dm-standortmenu__griffzone {
padding: 10px 0 14px;
@@ -1853,7 +1860,8 @@ button.dm-listenzeile {
}
.dm-marke img {
- height: 13px;
+ /* Auf Zeilenhoehe, siehe .dm-markenlogo weiter unten. */
+ height: 16px;
width: auto;
}
@@ -1874,3 +1882,58 @@ button.dm-listenzeile {
.dm-sd-knoepfe > * {
flex: 1;
}
+
+/* --------------------------------------------------- Beleg-Fenster
+ Deckungsgleich mit .beleg-drop in audi-dashboard.css: gestrichelter Rahmen,
+ 14px Radius, 28px Innenabstand, mittig, 15px kraeftige Zeile ueber 12.5px
+ gedaempfter. `--darueber` ist die Rueckmeldung beim Ziehen (nur am Rechner
+ erreichbar, am Telefon gibt es keine Ziehgeste fuer Dateien). */
+.dm-beleg-drop {
+ margin-top: var(--sp-3);
+ border: 1px dashed var(--line-strong);
+ border-radius: 14px;
+ padding: 28px var(--sp-4);
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.dm-beleg-drop strong {
+ font-size: 15px;
+ color: var(--fg);
+}
+.dm-beleg-drop span {
+ font-size: 12.5px;
+ color: var(--fg2);
+}
+.dm-beleg-drop--darueber {
+ border-color: var(--fg);
+ border-style: solid;
+ background: var(--shade);
+}
+
+.dm-beleg-knoepfe {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+ margin-top: var(--sp-3);
+}
+/* Solldruck: vier Zeichen ("2,40"), nicht die volle Feldbreite.
+ Deckungsgleich mit .druckfeld in audi-dashboard.css (58px). Die Klasse
+ stand hier schon im Markup, hatte aber keine Regel - die Felder nahmen
+ deshalb die ganze Zeile ein. */
+.ads-feld input.druckfeld {
+ width: 58px;
+ min-width: 0;
+ flex: none;
+ text-align: right;
+}
+
+/* Markenlogo (Shell u. a.) auf die Hoehe der Feldbeschriftung daneben -
+ 16px, siehe .ads-feld label. Mit 14px wirkte es neben "SmartDeal aktiv"
+ zu klein (gemeldet vom Eigentuemer, 2026-08-31). */
+.dm-markenlogo {
+ height: 16px;
+ width: auto;
+ vertical-align: middle;
+}
diff --git a/companion-app/src/symbole.tsx b/companion-app/src/symbole.tsx
index c9bcea1..85c1cae 100644
--- a/companion-app/src/symbole.tsx
+++ b/companion-app/src/symbole.tsx
@@ -70,16 +70,16 @@ export function SymbolAudi({ groesse }: SymbolProps) {
)
}
+/* CI-Symbol tour-s statt der frueheren handgezeichneten Route. Eine
+ Fuellflaeche, keine Kontur - deshalb voll, wie bei SymbolAudi und
+ SymbolStatistik. */
export function SymbolFahrten({ groesse }: SymbolProps) {
return (
-
-
-
-
+
}
/>
)
diff --git a/companion-app/src/theme.test.ts b/companion-app/src/theme.test.ts
index 5263d9b..b17815b 100644
--- a/companion-app/src/theme.test.ts
+++ b/companion-app/src/theme.test.ts
@@ -1,23 +1,40 @@
-import { beforeEach, describe, expect, it } from "vitest"
+/**
+ * Der Startwert des Themas wird beim Laden des Moduls **einmal** aus der
+ * Ablage gelesen und danach im Speicher gehalten — das verlangt
+ * `useSyncExternalStore`, dessen Momentaufnahme zwischen zwei Änderungen
+ * stabil sein muss (siehe theme.ts).
+ *
+ * Deshalb muss jeder Fall das Modul frisch laden, statt die Ablage zu
+ * beschreiben und dieselbe Instanz erneut zu fragen. Das ist keine
+ * Umständlichkeit des Tests, sondern genau das, was in der App passiert: die
+ * gespeicherte Wahl wirkt beim Start.
+ */
-import { themeStartwert } from "./theme"
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+async function startwertNachLaden(): Promise {
+ vi.resetModules()
+ const { themeStartwert } = await import("./theme")
+ return themeStartwert()
+}
describe("Theme", () => {
beforeEach(() => {
localStorage.clear()
})
- it("startet ohne gespeicherte Wahl im Nachtmodus", () => {
- expect(themeStartwert()).toBe("nacht")
+ it("startet ohne gespeicherte Wahl im Nachtmodus", async () => {
+ expect(await startwertNachLaden()).toBe("nacht")
})
- it("übernimmt eine gespeicherte Wahl", () => {
+ it("übernimmt eine gespeicherte Wahl", async () => {
localStorage.setItem("dm360.theme", "tag")
- expect(themeStartwert()).toBe("tag")
+ expect(await startwertNachLaden()).toBe("tag")
})
- it("ignoriert unsinnige gespeicherte Werte", () => {
+ it("ignoriert unsinnige gespeicherte Werte", async () => {
localStorage.setItem("dm360.theme", "irgendwas")
- expect(themeStartwert()).toBe("nacht")
+ expect(await startwertNachLaden()).toBe("nacht")
})
+
})
diff --git a/companion-app/src/theme.test.tsx b/companion-app/src/theme.test.tsx
new file mode 100644
index 0000000..a15aff5
--- /dev/null
+++ b/companion-app/src/theme.test.tsx
@@ -0,0 +1,73 @@
+/**
+ * Regression: die Tag/Nacht-Umschaltung blieb wirkungslos.
+ *
+ * Jeder Aufruf von `useTheme()` legte bis 2026-08-31 sein eigenes `useState`
+ * an. Der Schalter in den Einstellungen änderte damit nur seine eigene Kopie;
+ * die Kopie in `App.tsx`, die `data-theme` setzt, blieb stehen — sichtbar
+ * wurde die Wahl erst nach einem Neustart der App. Der Boden-Schalter hatte
+ * denselben Fehler.
+ *
+ * Der Test stellt genau diese Konstellation nach: zwei getrennte Komponenten,
+ * eine schaltet, die andere zeigt an.
+ */
+
+import { fireEvent, render, screen } from "@testing-library/react"
+import { beforeEach, describe, expect, it } from "vitest"
+
+import { useBoden, useTheme } from "./theme"
+
+function Anzeige() {
+ const [theme] = useTheme()
+ const [boden] = useBoden()
+ return (
+
+ {theme}
+ {boden ? "an" : "aus"}
+
+ )
+}
+
+function Schalter() {
+ const [theme, setzeTheme] = useTheme()
+ const [boden, setzeBoden] = useBoden()
+ return (
+
+ setzeTheme(theme === "nacht" ? "tag" : "nacht")}>Thema
+ setzeBoden(!boden)}>Boden
+
+ )
+}
+
+describe("Ansichtseinstellungen, geteilt zwischen Komponenten", () => {
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ it("die Themenwahl der einen Komponente erreicht die andere", () => {
+ render(
+ <>
+
+
+ >,
+ )
+ const vorher = screen.getByTestId("theme").textContent
+
+ fireEvent.click(screen.getByText("Thema"))
+
+ expect(screen.getByTestId("theme").textContent).not.toBe(vorher)
+ })
+
+ it("der Boden-Schalter der einen Komponente erreicht die andere", () => {
+ render(
+ <>
+
+
+ >,
+ )
+ const vorher = screen.getByTestId("boden").textContent
+
+ fireEvent.click(screen.getByText("Boden"))
+
+ expect(screen.getByTestId("boden").textContent).not.toBe(vorher)
+ })
+})
diff --git a/companion-app/src/theme.ts b/companion-app/src/theme.ts
index d4a01b8..3782f5a 100644
--- a/companion-app/src/theme.ts
+++ b/companion-app/src/theme.ts
@@ -1,58 +1,128 @@
/**
- * Tag/Nacht-Umschaltung, übernommen aus dem alten Panel (A6 des Design-Audits):
- * gespeicherte Wahl schlägt Systemeinstellung, Standard ist "nacht".
- * Alle localStorage-Zugriffe sind abgesichert, weil sie im Privatmodus mancher
- * Browser werfen.
+ * Ansichtseinstellungen: Tag/Nacht, Fahrbahn unter dem Fahrzeug, Beschriftung
+ * der Menüleiste. Übernommen aus dem alten Panel (A6 des Design-Audits):
+ * gespeicherte Wahl schlägt Systemeinstellung, Standard für das Thema ist
+ * „nacht".
+ *
+ * WARUM HIER EIN GETEILTER SPEICHER STEHT UND NICHT DREIMAL useState
+ * ------------------------------------------------------------------
+ * Bis 2026-08-31 legte jeder dieser Haken sein eigenes `useState` an. Wer ihn
+ * zweimal aufrief, bekam zwei voneinander unabhängige Kopien desselben Werts:
+ *
+ * useTheme() → App.tsx (setzt data-theme), Einstellungen.tsx (der Schalter),
+ * Typenschild.tsx (zweimal, für die Farbe des Schilds)
+ * useBoden() → Einstellungen.tsx (der Schalter), Uebersicht.tsx (die Anzeige)
+ *
+ * Der Schalter in den Einstellungen änderte damit nur seine eigene Kopie und
+ * den Speicher der Ablage — die Kopie in App.tsx blieb stehen, `data-theme`
+ * wechselte nie, und die Umschaltung wirkte erst nach einem Neustart der App.
+ * Genau so gemeldet: „Tag/Nacht Umschaltung wirkungslos". Der Boden-Schalter
+ * hatte denselben Fehler, nur ist er weniger auffällig.
+ *
+ * `useSyncExternalStore` (in React eingebaut) löst das an der Wurzel: es gibt
+ * je Einstellung genau einen Wert, jeder Aufrufer sieht denselben, und ein
+ * Setzen benachrichtigt alle. Das ist der React-eigene Weg für einen Zustand,
+ * der außerhalb des Komponentenbaums lebt — kein Kontext-Anbieter nötig, und
+ * die Aufrufstellen bleiben unverändert.
+ *
+ * Alle Zugriffe auf die Ablage sind abgesichert: im Privatmodus mancher
+ * Browser wirft schon das Lesen.
*/
-import { useCallback, useEffect, useState } from "react"
+import { useCallback, useEffect, useSyncExternalStore } from "react"
export type Theme = "nacht" | "tag"
-const SCHLUESSEL = "dm360.theme"
-
-function gespeichertLesen(): Theme | null {
+function ablageLesen(schluessel: string): string | null {
try {
- const wert = localStorage.getItem(SCHLUESSEL)
- return wert === "nacht" || wert === "tag" ? wert : null
+ return localStorage.getItem(schluessel)
} catch {
return null
}
}
-function gespeichertSchreiben(theme: Theme): void {
+function ablageSchreiben(schluessel: string, wert: string): void {
try {
- localStorage.setItem(SCHLUESSEL, theme)
+ localStorage.setItem(schluessel, wert)
} catch {
- // Privatmodus — die Wahl gilt dann nur für diese Sitzung
+ // Privatmodus — die Wahl gilt dann nur für diese Sitzung.
}
}
+/**
+ * Ein Wert, den sich alle Aufrufer teilen.
+ *
+ * `wert` wird beim ersten Zugriff aus der Ablage gelesen und danach im
+ * Speicher gehalten — `useSyncExternalStore` verlangt, dass zwei Aufrufe von
+ * `stand()` ohne zwischenzeitliche Änderung dasselbe liefern. Bei jedem Mal
+ * neu aus der Ablage zu lesen genügt dem zwar auch, aber nur zufällig; ein
+ * gehaltener Wert macht die Zusage explizit.
+ */
+function einstellung(
+ schluessel: string,
+ ausAblage: (roh: string | null) => T,
+ inAblage: (wert: T) => string,
+) {
+ let wert = ausAblage(ablageLesen(schluessel))
+ const horcher = new Set<() => void>()
+
+ return {
+ stand: () => wert,
+ horchen: (bei: () => void) => {
+ horcher.add(bei)
+ return () => horcher.delete(bei)
+ },
+ setzen: (neu: T) => {
+ if (neu === wert) return
+ wert = neu
+ ablageSchreiben(schluessel, inAblage(neu))
+ for (const bei of horcher) bei()
+ },
+ }
+}
+
+const THEMA = einstellung(
+ "dm360.theme",
+ (roh) => {
+ if (roh === "nacht" || roh === "tag") return roh
+ // Ohne gespeicherte Wahl entscheidet das System; ohne beides: nacht.
+ const hell =
+ typeof window !== "undefined" &&
+ typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-color-scheme: light)").matches
+ return hell ? "tag" : "nacht"
+ },
+ (wert) => wert,
+)
+
+const BODEN = einstellung(
+ "dm360.boden",
+ (roh) => roh !== "aus",
+ (an) => (an ? "an" : "aus"),
+)
+
+const TABBESCHRIFTUNG = einstellung(
+ "dm360.tabbeschriftung",
+ (roh) => roh !== "aus",
+ (an) => (an ? "an" : "aus"),
+)
+
+/** Nur für Tests: setzt den gehaltenen Wert auf den Stand der Ablage zurück. */
export function themeStartwert(): Theme {
- const gespeichert = gespeichertLesen()
- if (gespeichert) return gespeichert
- const hell =
- typeof window !== "undefined" &&
- typeof window.matchMedia === "function" &&
- window.matchMedia("(prefers-color-scheme: light)").matches
- return hell ? "tag" : "nacht"
+ return THEMA.stand()
}
export function useTheme(): [Theme, (theme: Theme) => void] {
- const [theme, setzeTheme] = useState(themeStartwert)
-
- const setzen = useCallback((neu: Theme) => {
- setzeTheme(neu)
- gespeichertSchreiben(neu)
- }, [])
+ const theme = useSyncExternalStore(THEMA.horchen, THEMA.stand, THEMA.stand)
+ const setzen = useCallback((neu: Theme) => THEMA.setzen(neu), [])
// Farbe der Systemleisten mitziehen, damit auf dem Handy kein heller
- // Balken über einer dunklen App steht.
+ // Balken über einer dunklen App steht. Werte des Panels (--canvas in
+ // audi-dashboard-ios.css); hier standen einmal #161b23/#ffffff aus dem
+ // Basis-Stylesheet, das im Panel nie greift — die Systemleiste hatte damit
+ // einen anderen Ton als die App darunter.
useEffect(() => {
const marke = document.querySelector('meta[name="theme-color"]')
- // Werte des Panels (--canvas in audi-dashboard-ios.css). Hier standen noch
- // #161b23/#ffffff aus dem Basis-Stylesheet, das im Panel nie greift —
- // die Systemleiste hatte damit einen anderen Ton als die App darunter.
if (marke) marke.setAttribute("content", theme === "nacht" ? "#0C1014" : "#F2F2F7")
}, [theme])
@@ -62,52 +132,21 @@ export function useTheme(): [Theme, (theme: Theme) => void] {
/**
* Bodenschatten unter dem Fahrzeugbild — im Panel der Schalter „Boden" in den
* Einstellungen (`bodenAn`). Dort lebt er nur im Speicher und ist nach einem
- * Neuladen wieder an; hier folgt er dem Muster der übrigen Ansichtsschalter
- * und bleibt erhalten, Standard wie im Panel: an.
+ * Neuladen wieder an; hier bleibt er erhalten, Standard wie im Panel: an.
*/
-const BODEN_SCHLUESSEL = "dm360.boden"
-
export function useBoden(): [boolean, (an: boolean) => void] {
- const [an, setzeAn] = useState(() => {
- try {
- return localStorage.getItem(BODEN_SCHLUESSEL) !== "aus"
- } catch {
- return true
- }
- })
-
- const setzen = useCallback((neu: boolean) => {
- setzeAn(neu)
- try {
- localStorage.setItem(BODEN_SCHLUESSEL, neu ? "an" : "aus")
- } catch {
- // Privatmodus
- }
- }, [])
-
+ const an = useSyncExternalStore(BODEN.horchen, BODEN.stand, BODEN.stand)
+ const setzen = useCallback((neu: boolean) => BODEN.setzen(neu), [])
return [an, setzen]
}
/** Sichtbarkeit der Tab-Beschriftungen, ebenfalls dauerhaft gespeichert. */
-const BESCHRIFTUNG_SCHLUESSEL = "dm360.tabbeschriftung"
-
export function useTabBeschriftung(): [boolean, (an: boolean) => void] {
- const [an, setzeAn] = useState(() => {
- try {
- return localStorage.getItem(BESCHRIFTUNG_SCHLUESSEL) !== "aus"
- } catch {
- return true
- }
- })
-
- const setzen = useCallback((neu: boolean) => {
- setzeAn(neu)
- try {
- localStorage.setItem(BESCHRIFTUNG_SCHLUESSEL, neu ? "an" : "aus")
- } catch {
- // Privatmodus
- }
- }, [])
-
+ const an = useSyncExternalStore(
+ TABBESCHRIFTUNG.horchen,
+ TABBESCHRIFTUNG.stand,
+ TABBESCHRIFTUNG.stand,
+ )
+ const setzen = useCallback((neu: boolean) => TABBESCHRIFTUNG.setzen(neu), [])
return [an, setzen]
}
diff --git a/custom_components/audi_dashboard/einstellungen.py b/custom_components/audi_dashboard/einstellungen.py
index eb75b50..7698c52 100644
--- a/custom_components/audi_dashboard/einstellungen.py
+++ b/custom_components/audi_dashboard/einstellungen.py
@@ -159,94 +159,116 @@ FELDER: list[dict] = [
{"key": "ZUENDUNG_SENSOR", "label": "Zündung/ACC-Status", "gruppe": "fahrterkennung",
"hinweis": "on = Fahrt läuft, erkennt Fahrtbeginn/-ende.",
"domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": True,
+ "beispiel": "engine_ignition_or_acc_status",
"stichworte": ["zündung", "ignition", "acc", "motor", "engine"]},
{"key": "KM_SENSOR", "label": "Kilometerstand", "gruppe": "fahrterkennung",
"hinweis": "Gesamt-Kilometerstand des Fahrzeugs - für Fahrtabschluss-Screening, Reifenzähler und Ölwechsel-Prognose.",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
+ "beispiel": "total_vehicle_mileage_read_from_can",
"stichworte": ["kilometer", "km", "mileage", "odometer", "tacho", "laufleistung"]},
{"key": "TANK_SENSOR", "label": "Tankfüllstand", "gruppe": "fahrterkennung",
"hinweis": "Füllstand in Prozent - für die automatische Tankerkennung.",
"domains": ["sensor"], "device_classes": [], "units": ["%"], "liste": False, "pflicht": False,
+ "beispiel": "fuel_level_in_tank_read_from_can",
"stichworte": ["tank", "fuel", "kraftstoff", "füllstand", "level"]},
{"key": "TANK_LITER_SENSOR", "label": "Tankvolumen", "gruppe": "fahrterkennung",
"hinweis": "Optional, vom CAN statt aus dem Prozentwert umgerechnet - macht die automatische "
"Tankerkennung genauer und liefert eine Vorbelegung für die getankte Menge.",
"domains": ["sensor"], "device_classes": ["volume"], "units": ["L", "l"], "liste": False, "pflicht": False,
+ "beispiel": "can_fuel_volume",
"stichworte": ["tankvolumen", "liter", "litres", "fuel volume", "kraftstoffvolumen"]},
{"key": "TANK_DISTANZ_SENSOR", "label": "Strecke seit letzter Tankung", "gruppe": "fahrterkennung",
"hinweis": "Optional, vom Fahrzeug selbst berechnet - ersetzt die eigene Kilometerstand-Rechnung "
"für die Strecke eines Tankvorgangs.",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
+ "beispiel": "calculated_distance_covered_after_the_latest_refueling",
"stichworte": ["strecke tankung", "distance refuel", "distance since refueling", "refuelling"]},
{"key": "RANGE_SENSOR", "label": "Reichweite", "gruppe": "uebersicht",
"hinweis": "Für die Übersicht - bleibt sie leer, zeigt die Oberfläche \"unbekannt\".",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
+ "beispiel": "vehicle_range_read_from_can",
"stichworte": ["reichweite", "range"]},
{"key": "BATTERIE_SENSOR", "label": "12V-Batteriespannung", "gruppe": "uebersicht",
"hinweis": "Nicht jede Integration liefert diesen Wert - bleibt er leer, zeigt \"Mein Audi\" \"unbekannt\".",
"domains": ["sensor"], "device_classes": ["voltage"], "units": ["V"], "liste": False, "pflicht": False,
+ "beispiel": "external_power_voltage",
"stichworte": ["batterie", "battery", "spannung", "volt", "12v"]},
{"key": "AUSSENTEMP_SENSOR", "label": "Außentemperatur", "gruppe": "uebersicht",
"hinweis": "CAN-Außentemperatur - optional, ergänzt die Batteriespannungs-Messwertliste um den "
"Temperaturwert je Tag.",
"domains": ["sensor"], "device_classes": ["temperature"], "units": ["°C", "°F"], "liste": False,
- "pflicht": False, "stichworte": ["außentemperatur", "aussentemperatur", "ambient", "temperatur", "temperature"]},
+ "pflicht": False, "beispiel": "can_ambient_air_temperature",
+ "stichworte": ["außentemperatur", "aussentemperatur", "ambient", "temperatur", "temperature"]},
{"key": "REFRESH_BUTTON", "label": "Sofort-Aktualisierung", "gruppe": "uebersicht",
"hinweis": "Der von der Integration bereitgestellte Knopf für \"Ziehen zum Aktualisieren\".",
"domains": ["button"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
+ "beispiel": "refresh_now",
"stichworte": ["refresh", "aktualisieren", "update", "abrufen", "poll"]},
{"key": "TUER_SENSOREN", "label": "Türen", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu. Für \"Sicher abgestellt\" wichtig.",
"domains": ["binary_sensor"], "device_classes": ["door"], "units": [], "liste": True, "positionen": POSITIONEN,
- "pflicht": False, "stichworte": ["tür", "door"]},
+ "pflicht": False, "beispiel": "front_left_door",
+ "stichworte": ["tür", "door"]},
{"key": "FENSTER_SENSOREN", "label": "Fenster", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["window"], "units": [], "liste": True, "positionen": POSITIONEN,
- "pflicht": False, "stichworte": ["fenster", "window"]},
+ "pflicht": False, "beispiel": "front_left_window",
+ "stichworte": ["fenster", "window"]},
{"key": "HECKKLAPPE_SENSOR", "label": "Heckklappe", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
+ "beispiel": "tailgate",
"stichworte": ["heckklappe", "tailgate", "kofferraum", "trunk"]},
{"key": "HAUBE_SENSOR", "label": "Motorhaube", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
+ "beispiel": "hood",
"stichworte": ["haube", "hood", "bonnet", "motorhaube"]},
{"key": "TUERSCHLOSS_SENSOR", "label": "Türschloss", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = verriegelt. Die Zentralverriegelung schließt alle Türen gemeinsam - "
"die Fahrertür genügt für \"Fahrzeug verriegelt\".",
"domains": ["binary_sensor"], "device_classes": ["lock"], "units": [], "liste": False,
- "pflicht": False, "stichworte": ["türschloss", "schloss", "lock", "verriegelt", "fahrertür"]},
+ "pflicht": False, "beispiel": "front_left_door_lock",
+ "stichworte": ["türschloss", "schloss", "lock", "verriegelt", "fahrertür"]},
{"key": "DACH_SENSOR", "label": "Schiebedach", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["window", "door", "opening"], "units": [], "liste": False, "pflicht": False,
+ "beispiel": "sunroof",
"stichworte": ["dach", "schiebedach", "sunroof", "roof"]},
{"key": "LICHT_SENSOR", "label": "Standlicht", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = kein Licht.",
"domains": ["binary_sensor"], "device_classes": ["light"], "units": [], "liste": False, "pflicht": False,
+ "beispiel": "parking_lights",
"stichworte": ["licht", "standlicht", "light", "parking lights"]},
{"key": "NAECHSTER_OELWECHSEL_SENSOR", "label": "Nächster Ölwechsel", "gruppe": "uebersicht",
"hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit, ergänzt die App-eigene Servicebuch-Prognose.",
"domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False,
+ "beispiel": "oil_change_due",
"stichworte": ["ölwechsel", "oil", "service", "change"]},
{"key": "OELWECHSEL_STRECKE_SENSOR", "label": "Nächster Ölwechsel", "gruppe": "uebersicht",
"hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
+ "beispiel": "oil_change_distance",
"stichworte": ["ölwechsel", "oil", "distance", "strecke"]},
{"key": "NAECHSTE_INSPEKTION_SENSOR", "label": "Nächste Inspektion", "gruppe": "uebersicht",
"hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit.",
"domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False,
+ "beispiel": "inspection_due",
"stichworte": ["inspektion", "inspection", "service"]},
{"key": "INSPEKTION_STRECKE_SENSOR", "label": "Nächste Inspektion", "gruppe": "uebersicht",
"hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
+ "beispiel": "inspection_distance",
"stichworte": ["inspektion", "inspection", "distance", "strecke"]},
{"key": "STANDORT_LAT_SENSOR", "label": "GPS-Standort: Breitengrad", "gruppe": "standort",
"hinweis": "Breitengrad als eigener Sensor (z. B. flespi).",
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
+ "beispiel": "latitude_coordinate_value",
"stichworte": ["breitengrad", "latitude", "lat", "standort", "gps", "position", "ortung"]},
{"key": "STANDORT_LON_SENSOR", "label": "GPS-Standort: Längengrad", "gruppe": "standort",
"hinweis": "Längengrad als eigener Sensor (z. B. flespi).",
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
+ "beispiel": "longitude_coordinate_value",
"stichworte": ["längengrad", "longitude", "lon", "lng", "standort", "gps", "position", "ortung"]},
]
diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json
index 0b5550c..6171525 100644
--- a/custom_components/audi_dashboard/frontend/app/bundle.json
+++ b/custom_components/audi_dashboard/frontend/app/bundle.json
@@ -1 +1 @@
-{"version":"2026.8.30.20","sha256":"28839ee2ba8d1d703bb688a0124a2930ffb45b0d92acfc05d9c9504b7141c144","bytes":257018,"gebaut":"2026-08-30T22:05:13Z"}
\ No newline at end of file
+{"version":"2026.8.31.1","sha256":"bd990f600b5bf81ce66105af8d49a77d31a2a4e3b4e5070858c38e1e8d23fcc9","bytes":266016,"gebaut":"2026-08-31T12:23:41Z"}
\ No newline at end of file
diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip
index d23102300ea3cc2080105e5d0d2df3a6776c3dfd..4f5033d864cc9e8b9437f7d0c8d6dcab50b418e8 100644
GIT binary patch
delta 136604
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