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 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 14:24:52 +02:00
parent 26f72930c3
commit b1411625aa
58 changed files with 2846 additions and 368 deletions
+120
View File
@@ -6068,3 +6068,123 @@ rendering bug in a view.
`custom_components/audi_dashboard/` directly. Do not reintroduce a copy. `custom_components/audi_dashboard/` directly. Do not reintroduce a copy.
- Security principle: HA is never publicly exposed; only narrowly scoped surfaces (MQTT broker, - Security principle: HA is never publicly exposed; only narrowly scoped surfaces (MQTT broker,
proxy allowlist) may be exposed, each by explicit decision. 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
`<a download>` 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
`<input type="date">` 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 `<span>`); 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, 7689 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).
+120
View File
@@ -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.
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Entitlements der Haupt-App. Bislang hatte sie keine eigene Datei - erst die
App-Gruppe der Share-Erweiterung macht eine noetig. Derselbe Bezeichner wie
in native/teilen/DataMetric360Share.entitlements.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.datametric360</string>
</array>
</dict>
</plist>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Die App-Gruppe ist der einzige gemeinsame Boden zwischen Erweiterung und
App - ohne sie kann die Erweiterung nichts uebergeben. Derselbe Bezeichner
muss in App.entitlements stehen und im Apple-Entwicklerportal als App Group
angelegt sein.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.datametric360</string>
</array>
</dict>
</plist>
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Info.plist der Share-Erweiterung.
NSExtensionActivationRule entscheidet, wann DataMetric360 im Teilen-Blatt
ueberhaupt auftaucht. Bewusst eng: genau EIN PDF, sonst nichts. Die
Erweiterung kann nur Tankbelege, und ein Eintrag, der bei jedem Foto und
jedem Link mitangeboten wird und dann ablehnt, ist schlechter als keiner.
NSExtensionPrincipalClass zeigt auf die Swift-Klasse ohne Storyboard -
die Erweiterung hat keine eigene Oberflaeche, sie nimmt die Datei entgegen
und oeffnet die App. Deshalb steht hier auch kein NSExtensionMainStoryboard.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>de</string>
<key>CFBundleDisplayName</key>
<string>DataMetric360</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsAttachmentsWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>
@@ -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)
}
}
+6 -1
View File
@@ -18,8 +18,12 @@
"@aparajita/capacitor-secure-storage": "^8.0.0", "@aparajita/capacitor-secure-storage": "^8.0.0",
"@audi-dash/ui": "*", "@audi-dash/ui": "*",
"@capacitor/android": "^8.5.0", "@capacitor/android": "^8.5.0",
"@capacitor/app": "^8.1.1",
"@capacitor/core": "^8.5.0", "@capacitor/core": "^8.5.0",
"@capacitor/filesystem": "^8.1.3",
"@capacitor/ios": "^8.5.0", "@capacitor/ios": "^8.5.0",
"@capacitor/preferences": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@capgo/capacitor-updater": "^8.51.14", "@capgo/capacitor-updater": "^8.51.14",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react": "^18.3.1", "react": "^18.3.1",
@@ -36,6 +40,7 @@
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.7", "vite": "^6.0.7",
"vitest": "^2.1.8" "vitest": "^2.1.8",
"xcode": "^3.0.1"
} }
} }
+43
View File
@@ -75,6 +75,49 @@ if [ ! -d ios ]; then
fi fi
npx cap sync ios 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)" echo "==> Archiv bauen und signieren (Team $TEAM)"
rm -rf "$AUSGABE" rm -rf "$AUSGABE"
mkdir -p "$AUSGABE" mkdir -p "$AUSGABE"
@@ -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 = [
"\t<key>CFBundleURLTypes</key>",
"\t<array>",
"\t\t<dict>",
"\t\t\t<key>CFBundleURLName</key>",
`\t\t\t<string>app.datametric360</string>`,
"\t\t\t<key>CFBundleURLSchemes</key>",
"\t\t\t<array>",
`\t\t\t\t<string>${SCHEMA}</string>`,
"\t\t\t</array>",
"\t\t</dict>",
"\t</array>",
"</dict>",
].join("\n")
const letztes = appPlist.lastIndexOf("</dict>")
if (letztes < 0) abbruch("ios/App/App/Info.plist hat keine schließende <dict>-Marke.")
appPlist = appPlist.slice(0, letztes) + eintrag + appPlist.slice(letztes + "</dict>".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("")
+32
View File
@@ -1,7 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { App as CapacitorApp } from "@capacitor/app"
import { DataMetricApi, zugangLesen } from "./api" import { DataMetricApi, zugangLesen } from "./api"
import { DatenAnbieter, useDaten } from "./daten/DatenKontext" import { DatenAnbieter, useDaten } from "./daten/DatenKontext"
import { geteiltenBelegAbholen, geteiltenBelegSetzen } from "./daten/geteilterBeleg"
import { startklarMelden } from "./daten/ota" import { startklarMelden } from "./daten/ota"
import { Shell } from "./Shell" import { Shell } from "./Shell"
import type { Route, SeitenName } from "./navigation" import type { Route, SeitenName } from "./navigation"
@@ -75,6 +78,35 @@ function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) {
void startklarMelden() 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 ( return (
<Shell <Shell
route={route} route={route}
+15 -1
View File
@@ -221,7 +221,21 @@ export function Shell({
> >
<VierRinge hoehe={17} /> <VierRinge hoehe={17} />
</button> </button>
) : 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. */
<span className="dm-kopf__gegengewicht" aria-hidden="true" />
)}
</header> </header>
) )
@@ -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<string, string>()
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<string, unknown>) {
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")
})
})
+122
View File
@@ -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<void> {
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<File | null> {
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)
}
}
+2 -1
View File
@@ -133,7 +133,8 @@ export function profilZuEinstellungen(profil: Profil): Einstellungen {
fahrzeugzusatz: text(fahrzeug, "zusatz"), fahrzeugzusatz: text(fahrzeug, "zusatz"),
fahrzeuguntertitel: text(fahrzeug, "untertitel"), fahrzeuguntertitel: text(fahrzeug, "untertitel"),
kennzeichen: text(fahrzeug, "kennzeichen"), 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, tankvolumen: zahl(fahrzeug, "tankvolumen_liter") ?? 58,
nachtVon: text(einst, "nacht_von", "22:00"), nachtVon: text(einst, "nacht_von", "22:00"),
nachtBis: text(einst, "nacht_bis", "06:00"), nachtBis: text(einst, "nacht_bis", "06:00"),
+73
View File
@@ -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<File> {
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
}
+129
View File
@@ -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<HTMLInputElement | null>(null)
const [fehler, setzeFehler] = useState<string | null>(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 && (
<div
className={`dm-beleg-drop${darueber ? " dm-beleg-drop--darueber" : ""}`}
onDragOver={(e) => {
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.")
}}
>
<strong>PDF hierher ziehen</strong>
<span>oder unten auswählen</span>
</div>
)}
{fehler && (
<p className="dm-fehler" role="alert" style={{ margin: 0 }}>
{fehler}
</p>
)}
</>
) : undefined
return (
<>
{/* Ausserhalb des Blattes: unsichtbar, muss aber im Baum stehen, damit
der Verweis darauf zeigt. */}
<input
ref={dateiwahl}
type="file"
accept="application/pdf,.pdf"
hidden
onChange={(e) => {
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. */}
<ActionSheet open={offen} onClose={beiAbbruch} actions={aktionen}>
{zusatz}
</ActionSheet>
</>
)
}
+9 -4
View File
@@ -23,7 +23,7 @@
import { useEffect, useRef, useState } from "react" 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 type { CsvImportErgebnis, DataMetricApi, Fahrt, Profil, Tankvorgang } from "../api"
import { de, eur } from "../format" import { de, eur } from "../format"
@@ -189,8 +189,13 @@ export function DatensatzPopup({
} }
return ( return (
<Popup open={offen} onClose={schliessen} variant="form" anchor="center"> /* Blatt statt schwebender Karte - Apples Vorgabe fuer eine vom Nutzer
<span className="ads-eyebrow">{modus === "sichern" ? "Datensatz sichern" : "Datensatz laden"}</span> angestossene Aufgabe auf dem Telefon. */
<Sheet
open={offen}
onClose={schliessen}
title={modus === "sichern" ? "Datensatz sichern" : "Datensatz laden"}
>
<p className="dm-fussnote"> <p className="dm-fussnote">
{modus === "sichern" {modus === "sichern"
? "Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in Tabellenprogrammen aufgehen." ? "Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in Tabellenprogrammen aufgehen."
@@ -237,7 +242,7 @@ export function DatensatzPopup({
Fertig Fertig
</ActionButton> </ActionButton>
</div> </div>
</Popup> </Sheet>
) )
} }
+58
View File
@@ -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 `<input type="date">` 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<HTMLInputElement>,
"value" | "onChange" | "type" | "className"
>) {
return (
<input
key={wert ? "gefuellt" : "leer"}
type="date"
className={className}
value={wert}
onChange={(e) => beiAenderung(e.target.value)}
{...rest}
/>
)
}
+37 -18
View File
@@ -6,11 +6,12 @@
import { useEffect, useRef, useState } from "react" 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 { DIENST_DOMAIN, zugangLesen, zugangVerwerfen } from "../api"
import type { Profil } from "../api" import type { Profil } from "../api"
import { eigeneVersion } from "../daten/appVersion" import { eigeneVersion } from "../daten/appVersion"
import { Datumsfeld } from "./Datumsfeld"
import { useDaten } from "../daten/DatenKontext" import { useDaten } from "../daten/DatenKontext"
import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota" import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota"
import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter" import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter"
@@ -175,9 +176,20 @@ export function Einstellungen({
// Bei Winterraedern zeigt die Seitenansicht das Winterfoto - wie bildInfo() // Bei Winterraedern zeigt die Seitenansicht das Winterfoto - wie bildInfo()
// im Panel, das hier dieselbe Vorschau liefert. // im Panel, das hier dieselbe Vorschau liefert.
const gewaehltesBild = 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 } ? { 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<EinstellungenWerte>) => const aendern = (teil: Partial<EinstellungenWerte>) =>
setzeEntwurf({ ...werte, ...teil }) setzeEntwurf({ ...werte, ...teil })
@@ -354,14 +366,23 @@ export function Einstellungen({
<Tile> <Tile>
<span className="ads-eyebrow">Bild der Übersicht</span> <span className="ads-eyebrow">Bild der Übersicht</span>
{/* 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). */}
<Feld label="Ansicht" last> <Feld label="Ansicht" last>
<select <select
className="dm-auswahl" className="dm-auswahl"
value={werte.startbild} value={werte.startbild}
onChange={(e) => aendern({ startbild: e.target.value })} onChange={(e) => {
const startbild = e.target.value
void profilSpeichern({ einstellungen: { ...werte, startbild } })
}}
> >
{BILDPLAETZE.map((platz) => ( {BILDPLAETZE.map((platz) => (
<option key={platz.datei} value={platz.datei}> <option key={platz.datei} value={platz.name}>
{platz.name} {platz.name}
</option> </option>
))} ))}
@@ -391,7 +412,7 @@ export function Einstellungen({
<img <img
src={markenlogoUrl()} src={markenlogoUrl()}
alt={werte.markenname} alt={werte.markenname}
style={{ height: 14, width: "auto" }} className="dm-markenlogo"
onError={(e) => { onError={(e) => {
e.currentTarget.style.display = "none" e.currentTarget.style.display = "none"
}} }}
@@ -442,23 +463,20 @@ export function Einstellungen({
)} )}
</Tile> </Tile>
<Popup {/* Blatt statt schwebender Karte: eine Eingabe, die der Nutzer selbst
angestoßen hat, kommt nach Apples Vorgabe von der unteren Kante -
in Daumenreichweite und mit sichtbarem Weg hinaus. Die alte Karte
saß mittig und war obendrein durchscheinend. */}
<Sheet
open={sdAbfrage} open={sdAbfrage}
onClose={() => setzeSdAbfrage(false)} onClose={() => setzeSdAbfrage(false)}
variant="form" title="SmartDeal"
anchor="center" description="Bis wann gilt der Rabatt?"
> >
<span className="ads-eyebrow">SmartDeal</span>
<Feld label="Gültig bis" last> <Feld label="Gültig bis" last>
<input <Datumsfeld wert={sdEntwurf} beiAenderung={setzeSdEntwurf} />
className="dm-eingabe"
type="date"
value={sdEntwurf}
onChange={(e) => setzeSdEntwurf(e.target.value)}
/>
</Feld> </Feld>
<div className="dm-sd-knoepfe"> <div className="dm-sd-knoepfe">
<ActionButton onClick={() => setzeSdAbfrage(false)}>Abbrechen</ActionButton>
<ActionButton <ActionButton
variant="primary" variant="primary"
disabled={!sdEntwurf} disabled={!sdEntwurf}
@@ -475,8 +493,9 @@ export function Einstellungen({
> >
Bestätigen Bestätigen
</ActionButton> </ActionButton>
<ActionButton onClick={() => setzeSdAbfrage(false)}>Abbrechen</ActionButton>
</div> </div>
</Popup> </Sheet>
{/* „Nacht beginnt"/„Nacht endet" sind entfallen: das Panel fuehrt sie {/* „Nacht beginnt"/„Nacht endet" sind entfallen: das Panel fuehrt sie
in den Einstellungen nicht, die Nachtgrenze steht dort nur als in den Einstellungen nicht, die Nachtgrenze steht dort nur als
+30 -3
View File
@@ -9,7 +9,7 @@
* ist, statt eine glaubwürdig aussehende Erfindung zu zeigen. * 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" 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 }) { 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 fahrt = fahrten.find((f) => f.trip_id === id)
const [bearbeitenOffen, setzeBearbeitenOffen] = useState(false) 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) { if (!fahrt) {
return ( 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 = () => { const loeschen = () => {
if (!fahrt || !bestaetigen("Fahrt löschen?")) return if (!fahrt || !bestaetigen("Fahrt löschen?")) return
void api.fahrtLoeschen(fahrt.trip_id) void api.fahrtLoeschen(fahrt.trip_id)
@@ -90,7 +112,12 @@ export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (n
<Fig value={deOderStrich(fahrt.distance_km)} unit="km" size={52} /> <Fig value={deOderStrich(fahrt.distance_km)} unit="km" size={52} />
</div> </div>
<div style={{ marginTop: "15px" }}> <div style={{ marginTop: "15px" }}>
<Pill>{fahrt.art === "arbeitsweg" ? "Arbeitsweg" : "Privat"}</Pill> {/* 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 <span>. */}
<Pill onClick={artUmschalten} ariaLabel={`Art der Fahrt umschalten, derzeit ${art === "arbeitsweg" ? "Arbeitsweg" : "Privat"}`}>
{art === "arbeitsweg" ? "Arbeitsweg" : "Privat"}
</Pill>
</div> </div>
</Tile> </Tile>
+1 -1
View File
@@ -69,7 +69,7 @@ export function Fahrzeugdaten() {
label="Ausführung" label="Ausführung"
wert={einstellungen.ausfuehrung || "noch offen"} wert={einstellungen.ausfuehrung || "noch offen"}
/> />
<Wertzeile label="Fahrgestellnummer" wert={fahrzeug.fin || ""} /> <Wertzeile label="FIN" wert={fahrzeug.fin || ""} />
<Wertzeile label="Motor und Leistung" wert={fahrzeug.details || ""} /> <Wertzeile label="Motor und Leistung" wert={fahrzeug.details || ""} />
<Wertzeile <Wertzeile
label="Erstzulassung" label="Erstzulassung"
+10 -4
View File
@@ -17,7 +17,7 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { ActionButton, Feld, Popup } from "@audi-dash/ui" import { ActionButton, Feld, Sheet } from "@audi-dash/ui"
import type { DataMetricApi, ImportErgebnis } from "../api" import type { DataMetricApi, ImportErgebnis } from "../api"
import { datumZeit, de } from "../format" import { datumZeit, de } from "../format"
@@ -150,8 +150,14 @@ export function HistorienImport({
const fertig = ergebnis !== null && !laeuft const fertig = ergebnis !== null && !laeuft
return ( return (
<Popup open={offen} onClose={schliessen} variant="form" anchor="center"> /* Blatt statt schwebender Karte - Apples Vorgabe fuer eine vom Nutzer
<span className="ads-eyebrow">Daten importieren aus Home Assistant</span> angestossene Eingabe auf dem Telefon. */
<Sheet
open={offen}
onClose={schliessen}
title="Daten importieren"
description="Aus dem Verlauf von Home Assistant."
>
{fertig ? ( {fertig ? (
<> <>
@@ -202,6 +208,6 @@ export function HistorienImport({
</div> </div>
</> </>
)} )}
</Popup> </Sheet>
) )
} }
+82 -6
View File
@@ -26,6 +26,81 @@ export interface Punkt {
const KACHEL_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" const KACHEL_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
const KACHEL_ZUSATZ = "© OpenStreetMap contributors" 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 =
`<div style="transform:rotate(${w}deg)">` +
'<svg width="13" height="13" viewBox="0 0 10 10" style="overflow:visible">' +
'<path d="M5 0.4 L8.8 9.6 L5 6.4 L1.2 9.6 Z" fill="none" stroke="#fff" stroke-width="1.8" stroke-linejoin="round" opacity=".95"/>' +
`<path d="M5 0.4 L8.8 9.6 L5 6.4 L1.2 9.6 Z" fill="${ROT}"/></svg></div>`
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({ export function Karte({
start, start,
ziel, ziel,
@@ -61,18 +136,19 @@ export function Karte({
if (ziel) { if (ziel) {
punkte.push([ziel.lat, ziel.lon]) 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") .bindTooltip("Ziel")
.addTo(karte) .addTo(karte)
// Echte Route, wenn vorhanden - sonst eine Verbindungslinie NUR zwischen // Echte Route, wenn vorhanden - sonst eine Verbindungslinie NUR zwischen
// den beiden echten Punkten, kein erfundener Verlauf dazwischen. // den beiden echten Punkten, kein erfundener Verlauf dazwischen.
const linie = route && route.length >= 2 ? route : punkte 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) { if (route && route.length >= 2) {
// Kleine Zwischenpunkte entlang der echten Strecke, wie im Panel. // Richtungspfeile statt der frueheren Punkte, wie im Panel. Nur auf
route.slice(1, -1).forEach((punkt) => { // einer echten Aufzeichnung: die gestrichelte Ersatzlinie zwischen
L.circleMarker(punkt, { radius: 2.5, color: "#F50537", weight: 0, fillColor: "#F50537", fillOpacity: 0.85 }).addTo(karte!) // 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) punkte.push(...route)
} }
} }
+6 -12
View File
@@ -17,6 +17,7 @@ import type { Reifensatz, ReifenArchivEintrag } from "../daten/profilAdapter"
import { datum, de, deOderStrich } from "../format" import { datum, de, deOderStrich } from "../format"
import { SymbolEdit, SymbolPaket, SymbolZahnradVoll } from "../symbole" import { SymbolEdit, SymbolPaket, SymbolZahnradVoll } from "../symbole"
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine" import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Datumsfeld } from "./Datumsfeld"
import { Bild } from "./Bild" import { Bild } from "./Bild"
import { kalenderDateiLaden } from "./kalender" import { kalenderDateiLaden } from "./kalender"
@@ -60,7 +61,7 @@ export function Reifen() {
}, },
}) })
const ziel = aktiv === "Sommer" ? "Winter" : "Sommer" const ziel = aktiv === "Sommer" ? "Winter" : "Sommer"
kalenderDateiLaden( await kalenderDateiLaden(
"Reifenwechsel", "Reifenwechsel",
wechselDatumAnzeige, wechselDatumAnzeige,
`Wechsel auf ${ziel}reifen`, `Wechsel auf ${ziel}reifen`,
@@ -301,12 +302,7 @@ export function Reifen() {
<Tile> <Tile>
<span className="ads-eyebrow">Wechseltermin</span> <span className="ads-eyebrow">Wechseltermin</span>
<Feld label="Geplant am"> <Feld label="Geplant am">
<input <Datumsfeld wert={wechselDatumAnzeige} beiAenderung={setzeWechselDatum} />
className="dm-eingabe"
type="date"
value={wechselDatumAnzeige}
onChange={(e) => setzeWechselDatum(e.target.value)}
/>
</Feld> </Feld>
<Feld label="Termin vereinbart" last> <Feld label="Termin vereinbart" last>
<Switch <Switch
@@ -401,11 +397,9 @@ function ArchivZeile({ eintrag }: { eintrag: ReifenArchivEintrag }) {
{bearbeiten ? ( {bearbeiten ? (
<> <>
<Feld label="Ersetzt am"> <Feld label="Ersetzt am">
<input <Datumsfeld
className="dm-eingabe" wert={form.ersetzt_am}
type="date" beiAenderung={(w) => setzeForm({ ...form, ersetzt_am: w })}
value={form.ersetzt_am}
onChange={(e) => setzeForm({ ...form, ersetzt_am: e.target.value })}
/> />
</Feld> </Feld>
<Feld label="Gefahrene Strecke" unit="km"> <Feld label="Gefahrene Strecke" unit="km">
+4 -13
View File
@@ -30,6 +30,7 @@ import {
Zahnradknopf, Zahnradknopf,
bestaetigen, bestaetigen,
} from "./bausteine" } from "./bausteine"
import { Datumsfeld } from "./Datumsfeld"
import { kalenderDateiLaden } from "./kalender" import { kalenderDateiLaden } from "./kalender"
import { vcardParsen } from "./vcard" import { vcardParsen } from "./vcard"
@@ -295,15 +296,10 @@ function TerminVereinbaren() {
</select> </select>
</Feld> </Feld>
<Feld label="Werkstatttermin am" last> <Feld label="Werkstatttermin am" last>
<input <Datumsfeld wert={datumWert} beiAenderung={(w) => void merken(w)} />
className="dm-eingabe"
type="date"
value={datumWert}
onChange={(e) => void merken(e.target.value)}
/>
</Feld> </Feld>
<ActionButton <ActionButton
onClick={() => kalenderDateiLaden(art, datumWert || isoTag(), `${art} in der Werkstatt`)} onClick={() => void kalenderDateiLaden(art, datumWert || isoTag(), `${art} in der Werkstatt`)}
> >
In den Kalender übernehmen In den Kalender übernehmen
</ActionButton> </ActionButton>
@@ -480,12 +476,7 @@ export function Servicebuch({
<Tile> <Tile>
<span className="ads-eyebrow">Eintrag</span> <span className="ads-eyebrow">Eintrag</span>
<Feld label="Datum"> <Feld label="Datum">
<input <Datumsfeld wert={entwurf.datum ?? ""} beiAenderung={(w) => setze("datum", w)} />
className="dm-eingabe"
type="date"
value={entwurf.datum ?? ""}
onChange={(e) => setze("datum", e.target.value)}
/>
</Feld> </Feld>
<Feld label="Kilometerstand" unit="km"> <Feld label="Kilometerstand" unit="km">
<input <input
+33 -7
View File
@@ -141,15 +141,32 @@ interface EigenePosition {
lon: number lon: number
} }
function eigenePositionErfassen( /**
* Fortlaufende Ortung dieses Geraets, solange die Standortseite offen ist.
*
* Bis 2026-08-31 stand hier getCurrentPosition - eine Momentaufnahme beim
* Oeffnen der Seite. Der eigene Punkt blieb danach stehen, auch wenn man
* weiterging, und der angezeigte Abstand zum Fahrzeug veraltete still.
*
* Die Rueckgabe ist die Abmeldung: der Aufrufer haengt sie an einen
* useEffect, damit die Ortung endet, sobald die Seite verlassen wird. Genau
* das meint Apples "Beim Verwenden der App" - erfassen, solange jemand die
* Karte ansieht, und keine Sekunde laenger.
*
* iOS zeigt die noetige Rueckfrage nur, wenn die native Huelle
* NSLocationWhenInUseUsageDescription im Info.plist fuehrt; eingetragen wird
* der Schluessel in scripts/ios-signieren.sh. Ohne ihn schlaegt schon der
* erste Aufruf fehl, unabhaengig von diesem Code.
*/
function standortVerfolgen(
setzePosition: (p: EigenePosition | null) => void, setzePosition: (p: EigenePosition | null) => void,
setzeFehler: (f: string | null) => void, setzeFehler: (f: string | null) => void,
): void { ): () => void {
if (!navigator.geolocation) { if (!navigator.geolocation) {
setzeFehler("GPS offline") setzeFehler("GPS offline")
return return () => {}
} }
navigator.geolocation.getCurrentPosition( const kennung = navigator.geolocation.watchPosition(
(pos) => { (pos) => {
setzeFehler(null) setzeFehler(null)
setzePosition({ lat: pos.coords.latitude, lon: pos.coords.longitude }) setzePosition({ lat: pos.coords.latitude, lon: pos.coords.longitude })
@@ -160,8 +177,11 @@ function eigenePositionErfassen(
// sonst "GPS offline" (deckungsgleich mit dem Panel). // sonst "GPS offline" (deckungsgleich mit dem Panel).
setzeFehler(err.code === err.PERMISSION_DENIED ? "GPS-Freigabe fehlt" : "GPS offline") 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: /** Kleine, nicht interaktive Kartenvorschau auf der Übersicht Vorlage:
@@ -227,7 +247,14 @@ export function Standort() {
const [satellit, setzeSatellit] = useState(false) const [satellit, setzeSatellit] = useState(false)
const [eigenePosition, setzeEigenePosition] = useState<EigenePosition | null>(null) const [eigenePosition, setzeEigenePosition] = useState<EigenePosition | null>(null)
const [positionsFehler, setzePositionsFehler] = useState<string | null>(null) const [positionsFehler, setzePositionsFehler] = useState<string | null>(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<string | null>(null) const [adresse, setzeAdresse] = useState<string | null>(null)
useEffect(() => standortVerfolgen(setzeEigenePosition, setzePositionsFehler), [ortungsLauf])
const [blattOffen, setzeBlattOffen] = useState(false) const [blattOffen, setzeBlattOffen] = useState(false)
const blattRef = useRef<HTMLDivElement | null>(null) const blattRef = useRef<HTMLDivElement | null>(null)
// Zug am Griff, deckungsgleich mit standortMenuVerdrahten() im Panel: das // 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) if (fahrzeugPos) karte.setView([fahrzeugPos.lat, fahrzeugPos.lon], 15)
else karte.setView([51.16, 10.45], 5) else karte.setView([51.16, 10.45], 5)
setTimeout(() => karte.invalidateSize(), 60) setTimeout(() => karte.invalidateSize(), 60)
eigenePositionErfassen(setzeEigenePosition, setzePositionsFehler)
}) })
return () => { return () => {
abgebrochen = true abgebrochen = true
@@ -457,7 +483,7 @@ export function Standort() {
type="button" type="button"
onClick={() => { onClick={() => {
if (!eigenePosition) { if (!eigenePosition) {
eigenePositionErfassen(setzeEigenePosition, setzePositionsFehler) setzeOrtungsLauf((l) => l + 1)
return return
} }
kartenRef.current?.flyTo([eigenePosition.lat, eigenePosition.lon], Math.max(kartenRef.current.getZoom(), 15)) kartenRef.current?.flyTo([eigenePosition.lat, eigenePosition.lon], Math.max(kartenRef.current.getZoom(), 15))
+12 -12
View File
@@ -7,7 +7,7 @@
* Genau dieses Muster hatte auch das alte Panel. * 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" 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 type { SeitenName } from "../navigation"
import { KRAFTSTOFFSORTEN, Leerzustand, Wertzeile, Werteliste, bestaetigen } from "./bausteine" import { KRAFTSTOFFSORTEN, Leerzustand, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { markenlogoUrl } from "./bilder" import { markenlogoUrl } from "./bilder"
import { BelegPopup } from "./BelegPopup"
import { TankstellenKarte } from "./TankstellenKarte" import { TankstellenKarte } from "./TankstellenKarte"
interface BelegErgebnis { interface BelegErgebnis {
@@ -316,7 +317,9 @@ function BelegKnopf({
belegDatei: string | null belegDatei: string | null
api: ReturnType<typeof useDaten>["api"] api: ReturnType<typeof useDaten>["api"]
}) { }) {
const dateiwahl = useRef<HTMLInputElement | null>(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 [laeuft, setzeLaeuft] = useState(false)
const [meldung, setzeMeldung] = useState<{ art: "ok" | "fehler"; text: string } | null>(null) const [meldung, setzeMeldung] = useState<{ art: "ok" | "fehler"; text: string } | null>(null)
@@ -384,15 +387,12 @@ function BelegKnopf({
return ( return (
<> <>
<input <BelegPopup
ref={dateiwahl} offen={belegFensterOffen}
type="file" beiAbbruch={() => setzeBelegFensterOffen(false)}
accept="application/pdf,.pdf" beiDatei={(datei) => {
hidden setzeBelegFensterOffen(false)
onChange={(e) => { void hochladen(datei)
const datei = e.target.files?.[0]
if (datei) void hochladen(datei)
e.target.value = ""
}} }}
/> />
{meldung && ( {meldung && (
@@ -403,7 +403,7 @@ function BelegKnopf({
{meldung.text} {meldung.text}
</p> </p>
)} )}
<ActionButton onClick={() => dateiwahl.current?.click()} disabled={laeuft || wartet}> <ActionButton onClick={() => setzeBelegFensterOffen(true)} disabled={laeuft || wartet}>
{laeuft {laeuft
? "Lade hoch …" ? "Lade hoch …"
: wartet : wartet
+45 -13
View File
@@ -6,7 +6,7 @@
* heben wie eine große. * 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" 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 { nachJahrUndMonat } from "../daten/statistik"
import { datum, de, eur, schnittpreis, summe } from "../format" import { datum, de, eur, schnittpreis, summe } from "../format"
import type { SeitenName } from "../navigation" import type { SeitenName } from "../navigation"
import {
geteiltenBelegHorchen,
geteiltenBelegSetzen,
geteiltenBelegStand,
} from "../daten/geteilterBeleg"
import { Blattzeile, KRAFTSTOFFSORTEN, Leerzustand } from "./bausteine" import { Blattzeile, KRAFTSTOFFSORTEN, Leerzustand } from "./bausteine"
import { BelegPopup } from "./BelegPopup"
import { markenlogoUrl } from "./bilder" import { markenlogoUrl } from "./bilder"
import { Zeilenmenue } from "./Zeilenmenue" import { Zeilenmenue } from "./Zeilenmenue"
@@ -23,6 +29,15 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
const { tankvorgaenge, fahrzeug, einstellungen, api } = useDaten() const { tankvorgaenge, fahrzeug, einstellungen, api } = useDaten()
const [formularOffen, setzeFormularOffen] = useState(false) 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 gruppen = nachJahrUndMonat(tankvorgaenge, (t) => t.ts)
const smartdealAktiv = fahrzeug?.smartdeal.aktiv === true const smartdealAktiv = fahrzeug?.smartdeal.aktiv === true
@@ -59,7 +74,8 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
<img <img
src={markenlogoUrl()} src={markenlogoUrl()}
alt={einstellungen?.markenname ?? "Logo"} alt={einstellungen?.markenname ?? "Logo"}
style={{ height: 14, verticalAlign: "middle", marginRight: 6 }} className="dm-markenlogo"
style={{ marginRight: 6 }}
onError={(e) => { onError={(e) => {
e.currentTarget.style.display = "none" e.currentTarget.style.display = "none"
}} }}
@@ -172,7 +188,26 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
einem bereits bestehenden Tankvorgang nachtragen. */ einem bereits bestehenden Tankvorgang nachtragen. */
const [beleg, setzeBeleg] = useState<BelegEntwurf | null>(null) const [beleg, setzeBeleg] = useState<BelegEntwurf | null>(null)
const [belegLaeuft, setzeBelegLaeuft] = useState(false) const [belegLaeuft, setzeBelegLaeuft] = useState(false)
const belegwahl = useRef<HTMLInputElement | null>(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 const smartdealAktiv = fahrzeug?.smartdeal.aktiv === true
@@ -348,18 +383,15 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
Felder und die Knöpfe. */} Felder und die Knöpfe. */}
{/* Drei Knöpfe wie in vFuelFormular(): erst den Beleg lesen lassen, dann {/* Drei Knöpfe wie in vFuelFormular(): erst den Beleg lesen lassen, dann
mit oder ohne ihn speichern. */} mit oder ohne ihn speichern. */}
<input <BelegPopup
ref={belegwahl} offen={belegFensterOffen}
type="file" beiAbbruch={() => setzeBelegFensterOffen(false)}
accept="application/pdf,.pdf" beiDatei={(datei) => {
hidden setzeBelegFensterOffen(false)
onChange={(e) => { void belegLesen(datei)
const datei = e.target.files?.[0]
if (datei) void belegLesen(datei)
e.target.value = ""
}} }}
/> />
<ActionButton onClick={() => belegwahl.current?.click()} disabled={belegLaeuft || laeuft}> <ActionButton onClick={() => setzeBelegFensterOffen(true)} disabled={belegLaeuft || laeuft}>
{belegLaeuft ? "Beleg wird gelesen …" : beleg ? "Beleg ersetzen" : "Beleg hochladen"} {belegLaeuft ? "Beleg wird gelesen …" : beleg ? "Beleg ersetzen" : "Beleg hochladen"}
</ActionButton> </ActionButton>
<ActionButton variant="primary" onClick={() => void speichern(true)} disabled={laeuft}> <ActionButton variant="primary" onClick={() => void speichern(true)} disabled={laeuft}>
+5 -1
View File
@@ -42,7 +42,11 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
// Bodenschatten unter dem Fahrzeug, wie szeneKlasse() im Panel: abschaltbar, // Bodenschatten unter dem Fahrzeug, wie szeneKlasse() im Panel: abschaltbar,
// nicht bei Innenraumaufnahmen, blaustichig bei montierten Winterrädern. // nicht bei Innenraumaufnahmen, blaustichig bei montierten Winterrädern.
const winterraeder = fahrzeug.reifen.aktiv === "Winter" 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() // Bei Winterrädern zeigt die Seitenansicht das Winterfoto, wie bildInfo()
// im Panel — das fehlte hier ganz. // im Panel — das fehlte hier ganz.
const startbild = bildInfo(platz, winterraeder) const startbild = bildInfo(platz, winterraeder)
+5 -11
View File
@@ -15,6 +15,7 @@ import { useDaten } from "../daten/DatenKontext"
import { datum, de, eur } from "../format" import { datum, de, eur } from "../format"
import type { SeitenName } from "../navigation" import type { SeitenName } from "../navigation"
import { Leerzustand, Wertzeile, Werteliste, Zahnradknopf } from "./bausteine" import { Leerzustand, Wertzeile, Werteliste, Zahnradknopf } from "./bausteine"
import { Datumsfeld } from "./Datumsfeld"
interface Beitragsposten { interface Beitragsposten {
bez?: string bez?: string
@@ -209,12 +210,7 @@ export function Beitrag() {
<Tile> <Tile>
<span className="ads-eyebrow">Beitrag anpassen</span> <span className="ads-eyebrow">Beitrag anpassen</span>
<Feld label="Gültig ab"> <Feld label="Gültig ab">
<input <Datumsfeld wert={gueltigAb} beiAenderung={setzeGueltigAb} />
className="dm-eingabe"
type="date"
value={gueltigAb}
onChange={(e) => setzeGueltigAb(e.target.value)}
/>
</Feld> </Feld>
<Feld label="Schadenfreiheitsklasse Haftpflicht"> <Feld label="Schadenfreiheitsklasse Haftpflicht">
<input <input
@@ -688,11 +684,9 @@ export function Steuer() {
</select> </select>
</Feld> </Feld>
<Feld label="Nächste Fälligkeit"> <Feld label="Nächste Fälligkeit">
<input <Datumsfeld
className="dm-eingabe" wert={(entwurf["faellig"] as string) ?? ""}
type="date" beiAenderung={(w) => setzeEntwurf((alt) => ({ ...alt, faellig: w }))}
value={(entwurf["faellig"] as string) ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, faellig: e.target.value }))}
/> />
</Feld> </Feld>
<Feld label="Lastschrift erteilt" last> <Feld label="Lastschrift erteilt" last>
+38 -24
View File
@@ -5,12 +5,13 @@
import { useRef, useState, type ReactNode } from "react" 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 type { SicherheitsPunkt } from "../api"
import { useDaten } from "../daten/DatenKontext" import { useDaten } from "../daten/DatenKontext"
import { SymbolZahnradVoll } from "../symbole" import { SymbolZahnradVoll } from "../symbole"
import { Bild } from "./Bild" import { Bild } from "./Bild"
import { bildVersionErneuern } from "./bilder"
/** Kraftstoffsorten zur Auswahl - dieselbe feste Liste wie `KRAFTSTOFFSORTEN` /** Kraftstoffsorten zur Auswahl - dieselbe feste Liste wie `KRAFTSTOFFSORTEN`
im Panel, geteilt zwischen Tanken.tsx (Neuanlegen) und TankDetail.tsx im Panel, geteilt zwischen Tanken.tsx (Neuanlegen) und TankDetail.tsx
@@ -83,18 +84,17 @@ export function BildMitMenue({
beschreibung, beschreibung,
name, name,
groesse, groesse,
menueLage,
dateiZeigen, dateiZeigen,
}: { }: {
datei: string datei: string
beschreibung: string beschreibung: string
name: string name: string
groesse?: "default" | "mini" | "round" groesse?: "default" | "mini" | "round"
/** "ansicht" = unter dem Bild zentriert, "rad" = links oben (wie im Panel). */ /* menueLage ist am 2026-08-31 entfallen: ein Aktionsblatt kommt immer von
menueLage?: "ansicht" | "rad" der unteren Kante, es gibt keine Lage mehr zu waehlen. */
dateiZeigen?: boolean dateiZeigen?: boolean
}) { }) {
const { api } = useDaten() const { api, jetztAktualisieren } = useDaten()
const [offen, setzeOffen] = useState(false) const [offen, setzeOffen] = useState(false)
const datei_ = useRef<HTMLInputElement | null>(null) const datei_ = useRef<HTMLInputElement | null>(null)
@@ -106,7 +106,13 @@ export function BildMitMenue({
leser.readAsDataURL(gewaehlt) leser.readAsDataURL(gewaehlt)
}) })
await api.bildHochladen(datei, base64) 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) setzeOffen(false)
void jetztAktualisieren()
} }
return ( return (
@@ -131,26 +137,34 @@ export function BildMitMenue({
e.target.value = "" e.target.value = ""
}} }}
/> />
{offen && ( {/* Auswahlblatt statt schwebendem Menü: zwei Wege, die der Nutzer
<> selbst angestoßen hat, plus ein abgesetztes Abbrechen - genau der
<div className="dm-bildmenu-fang" onClick={() => setzeOffen(false)} /> Zuschnitt, für den Apple das Aktionsblatt vorsieht. Das frühere
<div className={menueLage === "rad" ? "dm-bildmenu dm-bildmenu--rad" : "dm-bildmenu"}> Menü schwebte neben dem Bild, hatte keinen erkennbaren Ausweg und
<button type="button" onClick={() => datei_.current?.click()}> saß je nach Lage woanders. */}
Foto ersetzen <ActionSheet
</button> open={offen}
<button onClose={() => setzeOffen(false)}
type="button" title={name}
className="dm-bildmenu__loeschen" actions={[
onClick={() => { {
label: "Foto ersetzen",
onClick: () => datei_.current?.click(),
},
{
label: "Foto löschen",
destructive: true,
onClick: () => {
setzeOffen(false) setzeOffen(false)
if (bestaetigen("Foto löschen?")) void api.bildLoeschen(datei) if (!bestaetigen("Foto löschen?")) return
}} void api.bildLoeschen(datei).then(() => {
> bildVersionErneuern()
Löschen return jetztAktualisieren()
</button> })
</div> },
</> },
)} ]}
/>
</div> </div>
) )
} }
+27 -1
View File
@@ -63,6 +63,32 @@ export const ERLAUBTE_DATEINAMEN: readonly string[] = [
...WEITERE_BILDER, ...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 * Adresse eines Fahrzeugfotos auf dem Server. Die Bilder liegen unter
* `/local/bilder/` anders als beim alten Panel läuft die App nicht * `/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 if (!dateiname) return undefined
const basis = zugangLesenSynchron()?.basisUrl const basis = zugangLesenSynchron()?.basisUrl
if (!basis) return undefined if (!basis) return undefined
return `${basis}/local/bilder/${dateiname}` return `${basis}/local/bilder/${dateiname}?v=${bildVersion}`
} }
/** /**
@@ -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(<ImagePlaceholder src="/a.webp" alt="A" label="A" />)
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(<ImagePlaceholder src="/leer.webp" alt="A" label="A" />)
fireEvent.error(bild(container)!)
expect(platzhalter(container)).not.toBeNull()
// Weiterschalten in der Galerie: dieselbe Komponente, neue Adresse.
rerender(<ImagePlaceholder src="/foto.webp" alt="B" label="B" />)
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(<ImagePlaceholder src="/leer.webp" alt="A" label="A" />)
fireEvent.error(bild(container)!)
rerender(<ImagePlaceholder src="/foto.webp" alt="B" label="B" />)
expect(bild(container)).not.toBeNull()
// Zurück auf den bekannt leeren Platz: dort gehört der Platzhalter hin.
rerender(<ImagePlaceholder src="/leer.webp" alt="A" label="A" />)
expect(platzhalter(container)).not.toBeNull()
})
it("zeigt den Platzhalter ohne Adresse", () => {
const { container } = render(<ImagePlaceholder alt="A" label="Auto Seite" />)
expect(bild(container)).toBeNull()
expect(platzhalter(container)).not.toBeNull()
})
})
+44 -4
View File
@@ -1,10 +1,26 @@
/** /**
* Termine als Kalenderdatei. Portiert aus `ics()` im alten Panel. * Termine als Kalenderdatei. Portiert aus `ics()` im alten Panel.
* *
* Erzeugt eine .ics-Datei im Browser, ohne Server sie landet als Download * Erzeugt eine .ics-Datei ohne Server, mit Erinnerung zwei Tage vorher.
* bzw. auf dem iPhone direkt im Kalender. Mit Erinnerung zwei Tage vorher. *
* ZWEI WEGE, WEIL EIN DOWNLOAD IN DER APP NICHT ANKOMMT
* -----------------------------------------------------
* Im Browser und im HA-Panel genügt ein `<a download>` 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 { function zeilenUmbruch(text: string): string {
return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n") return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n")
} }
@@ -50,13 +66,37 @@ function hash(text: string): number {
return wert 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<void> {
const inhalt = kalenderDatei(titel, isoTag, beschreibung) 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 blob = new Blob([inhalt], { type: "text/calendar;charset=utf-8" })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const verweis = document.createElement("a") const verweis = document.createElement("a")
verweis.href = url verweis.href = url
verweis.download = `${titel.replace(/[^\wäöüÄÖÜß-]+/g, "-").toLowerCase()}.ics` verweis.download = name
document.body.appendChild(verweis) document.body.appendChild(verweis)
verweis.click() verweis.click()
verweis.remove() verweis.remove()
+12
View File
@@ -114,6 +114,18 @@
color: var(--fg); 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 { .dm-kopf__aktion {
min-width: 44px; min-width: 44px;
height: 44px; height: 44px;
+76 -13
View File
@@ -275,8 +275,12 @@ input[type="date"].dm-eingabe {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
height: 42%; /* 26/96 statt 42/78: der Verlauf soll die harte Bildkante ausblenden,
background: linear-gradient(to bottom, transparent, var(--canvas) 78%); 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; pointer-events: none;
z-index: 1; z-index: 1;
} }
@@ -1675,21 +1679,24 @@ button.dm-listenzeile {
gezaehlt, das Blatt war auf dem Geraet rund 16px hoeher als noetig und gezaehlt, das Blatt war auf dem Geraet rund 16px hoeher als noetig und
schob sich entsprechend weiter hoch. Am Rechner faellt das nicht auf, schob sich entsprechend weiter hoch. Am Rechner faellt das nicht auf,
dort ist der Einzug 0. */ dort ist der Einzug 0. */
padding: 0 20px 18px; padding: 0 20px 34px;
transform: translateY(calc(100% - var(--dm-standort-peek))); transform: translateY(calc(100% - var(--dm-standort-peek)));
transition: transform 0.28s cubic-bezier(0.22, 0.61, 0.36, 1); 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 /* Aufgezogen bleibt unter den Knoepfen ein fester Abstand stehen, statt dass
Inhaltshoehe. Apple beschreibt genau das als "detents": Hoehen, auf denen sie buendig mit der Menueleiste abschliessen.
ein Blatt natuerlich zur Ruhe kommt - large ist das voll ausgefahrene
Blatt, medium etwa die Haelfte davon. Vorher war die Rasthoehe schlicht Vorher stand hier min-height:50% - Apples "medium detent". Gemessen ergab
die Hoehe des Inhalts, dadurch schlossen die Knoepfe unten buendig mit der das 89px (Panel) bzw. 76px (App) Luft unter dem letzten Knopf, und das war
Menueleiste ab. Der Ueberschuss liegt unter den Knoepfen und haelt sie von sichtbar zu viel: das Blatt zog ueber die Haelfte des Bildschirms auf,
der Leiste frei. Die eingeklappte Hoehe bleibt unberuehrt: die Verschiebung obwohl sein Inhalt dort laengst zu Ende war. Eine Raste ist nur dann
rechnet mit 100% der jeweiligen Blatthoehe minus dem Guckwert. */ 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 { .dm-standortmenu--offen {
transform: translateY(0); transform: translateY(0);
min-height: 50%;
} }
.dm-standortmenu__griffzone { .dm-standortmenu__griffzone {
padding: 10px 0 14px; padding: 10px 0 14px;
@@ -1853,7 +1860,8 @@ button.dm-listenzeile {
} }
.dm-marke img { .dm-marke img {
height: 13px; /* Auf Zeilenhoehe, siehe .dm-markenlogo weiter unten. */
height: 16px;
width: auto; width: auto;
} }
@@ -1874,3 +1882,58 @@ button.dm-listenzeile {
.dm-sd-knoepfe > * { .dm-sd-knoepfe > * {
flex: 1; 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;
}
+5 -5
View File
@@ -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) { export function SymbolFahrten({ groesse }: SymbolProps) {
return ( return (
<Rahmen <Rahmen
voll
groesse={groesse} groesse={groesse}
kinder={ kinder={
<g> <path d="M3.4 3C2.6 3 2 3.64 2 4.5c0 .16.08.46.27.89a15.5 15.5 0 0 0 1.23 2.2 18.4 18.4 0 0 0 1.23-2.2c.19-.43.27-.73.27-.89C5 3.68 4.32 3 3.5 3zm.1 5.5-.41.28-.03-.03a18 18 0 0 1-.3-.45l-.7-1.14a14 14 0 0 1-.7-1.37A3.5 3.5 0 0 1 1 4.5C1 3.16 2 2 3.4 2h.1A2.5 2.5 0 0 1 6 4.5c0 .39-.17.86-.35 1.29a27 27 0 0 1-1.71 2.96l-.02.02v.01zm0 0 .41.28-.41.6-.41-.6zM12.4 3c-.8 0-1.4.64-1.4 1.5 0 .16.08.46.27.89a15.5 15.5 0 0 0 1.23 2.2 18.4 18.4 0 0 0 1.23-2.2c.19-.43.27-.73.27-.89 0-.82-.68-1.5-1.5-1.5zm.1 5.5-.41.28-.03-.03a12 12 0 0 1-.3-.45l-.7-1.14c-.25-.44-.5-.92-.7-1.37A3.5 3.5 0 0 1 10 4.5C10 3.16 11 2 12.4 2h.1A2.5 2.5 0 0 1 15 4.5c0 .39-.17.86-.35 1.29a27 27 0 0 1-1.71 2.96l-.02.02v.01zm0 0 .41.28-.41.6-.41-.6zm5.79-.5h.21a4.5 4.5 0 0 1 4.5 4.5c0 .66-.3 1.52-.67 2.38-.38.88-.9 1.83-1.4 2.7a54 54 0 0 1-1.97 3.14l-.04.05v.01l-.42-.28-.41.28-.01-.01-.04-.05a40 40 0 0 1-.58-.89 33 33 0 0 1-2.8-4.95A6.6 6.6 0 0 1 14 12.5 4.34 4.34 0 0 1 18.28 8Zm.21 12.5-.41.28.41.6.41-.6zm0-.9a53 53 0 0 0 1.57-2.53c.5-.85.98-1.76 1.35-2.6s.58-1.53.58-1.97c0-1.92-1.58-3.5-3.5-3.5h-.19c-1.94.1-3.4 1.58-3.31 3.48v.02c0 .44.2 1.13.58 1.98.37.83.86 1.74 1.35 2.6a53 53 0 0 0 1.57 2.52M9.94 11H3v-1h10.06l-7.61 5.44 13.29 7.12-.48.88-14.7-7.88L9.93 11Z" />
<circle cx="6" cy="6" r="2.2" />
<circle cx="18" cy="18" r="2.2" />
<path d="M8 7.5c4 1.5 4.5 3 3 5s-1 3.5 4.5 5" />
</g>
} }
/> />
) )
+25 -8
View File
@@ -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<string> {
vi.resetModules()
const { themeStartwert } = await import("./theme")
return themeStartwert()
}
describe("Theme", () => { describe("Theme", () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear() localStorage.clear()
}) })
it("startet ohne gespeicherte Wahl im Nachtmodus", () => { it("startet ohne gespeicherte Wahl im Nachtmodus", async () => {
expect(themeStartwert()).toBe("nacht") expect(await startwertNachLaden()).toBe("nacht")
}) })
it("übernimmt eine gespeicherte Wahl", () => { it("übernimmt eine gespeicherte Wahl", async () => {
localStorage.setItem("dm360.theme", "tag") 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") localStorage.setItem("dm360.theme", "irgendwas")
expect(themeStartwert()).toBe("nacht") expect(await startwertNachLaden()).toBe("nacht")
}) })
}) })
+73
View File
@@ -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 (
<div>
<span data-testid="theme">{theme}</span>
<span data-testid="boden">{boden ? "an" : "aus"}</span>
</div>
)
}
function Schalter() {
const [theme, setzeTheme] = useTheme()
const [boden, setzeBoden] = useBoden()
return (
<div>
<button onClick={() => setzeTheme(theme === "nacht" ? "tag" : "nacht")}>Thema</button>
<button onClick={() => setzeBoden(!boden)}>Boden</button>
</div>
)
}
describe("Ansichtseinstellungen, geteilt zwischen Komponenten", () => {
beforeEach(() => {
localStorage.clear()
})
it("die Themenwahl der einen Komponente erreicht die andere", () => {
render(
<>
<Anzeige />
<Schalter />
</>,
)
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(
<>
<Anzeige />
<Schalter />
</>,
)
const vorher = screen.getByTestId("boden").textContent
fireEvent.click(screen.getByText("Boden"))
expect(screen.getByTestId("boden").textContent).not.toBe(vorher)
})
})
+105 -66
View File
@@ -1,58 +1,128 @@
/** /**
* Tag/Nacht-Umschaltung, übernommen aus dem alten Panel (A6 des Design-Audits): * Ansichtseinstellungen: Tag/Nacht, Fahrbahn unter dem Fahrzeug, Beschriftung
* gespeicherte Wahl schlägt Systemeinstellung, Standard ist "nacht". * der Menüleiste. Übernommen aus dem alten Panel (A6 des Design-Audits):
* Alle localStorage-Zugriffe sind abgesichert, weil sie im Privatmodus mancher * gespeicherte Wahl schlägt Systemeinstellung, Standard für das Thema ist
* Browser werfen. * 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" export type Theme = "nacht" | "tag"
const SCHLUESSEL = "dm360.theme" function ablageLesen(schluessel: string): string | null {
function gespeichertLesen(): Theme | null {
try { try {
const wert = localStorage.getItem(SCHLUESSEL) return localStorage.getItem(schluessel)
return wert === "nacht" || wert === "tag" ? wert : null
} catch { } catch {
return null return null
} }
} }
function gespeichertSchreiben(theme: Theme): void { function ablageSchreiben(schluessel: string, wert: string): void {
try { try {
localStorage.setItem(SCHLUESSEL, theme) localStorage.setItem(schluessel, wert)
} catch { } catch {
// Privatmodus — die Wahl gilt dann nur für diese Sitzung // Privatmodus — die Wahl gilt dann nur für diese Sitzung.
} }
} }
export function themeStartwert(): Theme { /**
const gespeichert = gespeichertLesen() * Ein Wert, den sich alle Aufrufer teilen.
if (gespeichert) return gespeichert *
* `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<T>(
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<Theme>(
"dm360.theme",
(roh) => {
if (roh === "nacht" || roh === "tag") return roh
// Ohne gespeicherte Wahl entscheidet das System; ohne beides: nacht.
const hell = const hell =
typeof window !== "undefined" && typeof window !== "undefined" &&
typeof window.matchMedia === "function" && typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-color-scheme: light)").matches window.matchMedia("(prefers-color-scheme: light)").matches
return hell ? "tag" : "nacht" return hell ? "tag" : "nacht"
},
(wert) => wert,
)
const BODEN = einstellung<boolean>(
"dm360.boden",
(roh) => roh !== "aus",
(an) => (an ? "an" : "aus"),
)
const TABBESCHRIFTUNG = einstellung<boolean>(
"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 {
return THEMA.stand()
} }
export function useTheme(): [Theme, (theme: Theme) => void] { export function useTheme(): [Theme, (theme: Theme) => void] {
const [theme, setzeTheme] = useState<Theme>(themeStartwert) const theme = useSyncExternalStore(THEMA.horchen, THEMA.stand, THEMA.stand)
const setzen = useCallback((neu: Theme) => THEMA.setzen(neu), [])
const setzen = useCallback((neu: Theme) => {
setzeTheme(neu)
gespeichertSchreiben(neu)
}, [])
// Farbe der Systemleisten mitziehen, damit auf dem Handy kein heller // 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(() => { useEffect(() => {
const marke = document.querySelector('meta[name="theme-color"]') 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") if (marke) marke.setAttribute("content", theme === "nacht" ? "#0C1014" : "#F2F2F7")
}, [theme]) }, [theme])
@@ -62,52 +132,21 @@ export function useTheme(): [Theme, (theme: Theme) => void] {
/** /**
* Bodenschatten unter dem Fahrzeugbild im Panel der Schalter Boden" in den * Bodenschatten unter dem Fahrzeugbild im Panel der Schalter Boden" in den
* Einstellungen (`bodenAn`). Dort lebt er nur im Speicher und ist nach einem * Einstellungen (`bodenAn`). Dort lebt er nur im Speicher und ist nach einem
* Neuladen wieder an; hier folgt er dem Muster der übrigen Ansichtsschalter * Neuladen wieder an; hier bleibt er erhalten, Standard wie im Panel: an.
* und bleibt erhalten, Standard wie im Panel: an.
*/ */
const BODEN_SCHLUESSEL = "dm360.boden"
export function useBoden(): [boolean, (an: boolean) => void] { export function useBoden(): [boolean, (an: boolean) => void] {
const [an, setzeAn] = useState<boolean>(() => { const an = useSyncExternalStore(BODEN.horchen, BODEN.stand, BODEN.stand)
try { const setzen = useCallback((neu: boolean) => BODEN.setzen(neu), [])
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
}
}, [])
return [an, setzen] return [an, setzen]
} }
/** Sichtbarkeit der Tab-Beschriftungen, ebenfalls dauerhaft gespeichert. */ /** Sichtbarkeit der Tab-Beschriftungen, ebenfalls dauerhaft gespeichert. */
const BESCHRIFTUNG_SCHLUESSEL = "dm360.tabbeschriftung"
export function useTabBeschriftung(): [boolean, (an: boolean) => void] { export function useTabBeschriftung(): [boolean, (an: boolean) => void] {
const [an, setzeAn] = useState<boolean>(() => { const an = useSyncExternalStore(
try { TABBESCHRIFTUNG.horchen,
return localStorage.getItem(BESCHRIFTUNG_SCHLUESSEL) !== "aus" TABBESCHRIFTUNG.stand,
} catch { TABBESCHRIFTUNG.stand,
return true )
} const setzen = useCallback((neu: boolean) => TABBESCHRIFTUNG.setzen(neu), [])
})
const setzen = useCallback((neu: boolean) => {
setzeAn(neu)
try {
localStorage.setItem(BESCHRIFTUNG_SCHLUESSEL, neu ? "an" : "aus")
} catch {
// Privatmodus
}
}, [])
return [an, setzen] return [an, setzen]
} }
@@ -159,94 +159,116 @@ FELDER: list[dict] = [
{"key": "ZUENDUNG_SENSOR", "label": "Zündung/ACC-Status", "gruppe": "fahrterkennung", {"key": "ZUENDUNG_SENSOR", "label": "Zündung/ACC-Status", "gruppe": "fahrterkennung",
"hinweis": "on = Fahrt läuft, erkennt Fahrtbeginn/-ende.", "hinweis": "on = Fahrt läuft, erkennt Fahrtbeginn/-ende.",
"domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": True, "domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": True,
"beispiel": "engine_ignition_or_acc_status",
"stichworte": ["zündung", "ignition", "acc", "motor", "engine"]}, "stichworte": ["zündung", "ignition", "acc", "motor", "engine"]},
{"key": "KM_SENSOR", "label": "Kilometerstand", "gruppe": "fahrterkennung", {"key": "KM_SENSOR", "label": "Kilometerstand", "gruppe": "fahrterkennung",
"hinweis": "Gesamt-Kilometerstand des Fahrzeugs - für Fahrtabschluss-Screening, Reifenzähler und Ölwechsel-Prognose.", "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, "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"]}, "stichworte": ["kilometer", "km", "mileage", "odometer", "tacho", "laufleistung"]},
{"key": "TANK_SENSOR", "label": "Tankfüllstand", "gruppe": "fahrterkennung", {"key": "TANK_SENSOR", "label": "Tankfüllstand", "gruppe": "fahrterkennung",
"hinweis": "Füllstand in Prozent - für die automatische Tankerkennung.", "hinweis": "Füllstand in Prozent - für die automatische Tankerkennung.",
"domains": ["sensor"], "device_classes": [], "units": ["%"], "liste": False, "pflicht": False, "domains": ["sensor"], "device_classes": [], "units": ["%"], "liste": False, "pflicht": False,
"beispiel": "fuel_level_in_tank_read_from_can",
"stichworte": ["tank", "fuel", "kraftstoff", "füllstand", "level"]}, "stichworte": ["tank", "fuel", "kraftstoff", "füllstand", "level"]},
{"key": "TANK_LITER_SENSOR", "label": "Tankvolumen", "gruppe": "fahrterkennung", {"key": "TANK_LITER_SENSOR", "label": "Tankvolumen", "gruppe": "fahrterkennung",
"hinweis": "Optional, vom CAN statt aus dem Prozentwert umgerechnet - macht die automatische " "hinweis": "Optional, vom CAN statt aus dem Prozentwert umgerechnet - macht die automatische "
"Tankerkennung genauer und liefert eine Vorbelegung für die getankte Menge.", "Tankerkennung genauer und liefert eine Vorbelegung für die getankte Menge.",
"domains": ["sensor"], "device_classes": ["volume"], "units": ["L", "l"], "liste": False, "pflicht": False, "domains": ["sensor"], "device_classes": ["volume"], "units": ["L", "l"], "liste": False, "pflicht": False,
"beispiel": "can_fuel_volume",
"stichworte": ["tankvolumen", "liter", "litres", "fuel volume", "kraftstoffvolumen"]}, "stichworte": ["tankvolumen", "liter", "litres", "fuel volume", "kraftstoffvolumen"]},
{"key": "TANK_DISTANZ_SENSOR", "label": "Strecke seit letzter Tankung", "gruppe": "fahrterkennung", {"key": "TANK_DISTANZ_SENSOR", "label": "Strecke seit letzter Tankung", "gruppe": "fahrterkennung",
"hinweis": "Optional, vom Fahrzeug selbst berechnet - ersetzt die eigene Kilometerstand-Rechnung " "hinweis": "Optional, vom Fahrzeug selbst berechnet - ersetzt die eigene Kilometerstand-Rechnung "
"für die Strecke eines Tankvorgangs.", "für die Strecke eines Tankvorgangs.",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False, "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"]}, "stichworte": ["strecke tankung", "distance refuel", "distance since refueling", "refuelling"]},
{"key": "RANGE_SENSOR", "label": "Reichweite", "gruppe": "uebersicht", {"key": "RANGE_SENSOR", "label": "Reichweite", "gruppe": "uebersicht",
"hinweis": "Für die Übersicht - bleibt sie leer, zeigt die Oberfläche \"unbekannt\".", "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, "domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"beispiel": "vehicle_range_read_from_can",
"stichworte": ["reichweite", "range"]}, "stichworte": ["reichweite", "range"]},
{"key": "BATTERIE_SENSOR", "label": "12V-Batteriespannung", "gruppe": "uebersicht", {"key": "BATTERIE_SENSOR", "label": "12V-Batteriespannung", "gruppe": "uebersicht",
"hinweis": "Nicht jede Integration liefert diesen Wert - bleibt er leer, zeigt \"Mein Audi\" \"unbekannt\".", "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, "domains": ["sensor"], "device_classes": ["voltage"], "units": ["V"], "liste": False, "pflicht": False,
"beispiel": "external_power_voltage",
"stichworte": ["batterie", "battery", "spannung", "volt", "12v"]}, "stichworte": ["batterie", "battery", "spannung", "volt", "12v"]},
{"key": "AUSSENTEMP_SENSOR", "label": "Außentemperatur", "gruppe": "uebersicht", {"key": "AUSSENTEMP_SENSOR", "label": "Außentemperatur", "gruppe": "uebersicht",
"hinweis": "CAN-Außentemperatur - optional, ergänzt die Batteriespannungs-Messwertliste um den " "hinweis": "CAN-Außentemperatur - optional, ergänzt die Batteriespannungs-Messwertliste um den "
"Temperaturwert je Tag.", "Temperaturwert je Tag.",
"domains": ["sensor"], "device_classes": ["temperature"], "units": ["°C", "°F"], "liste": False, "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", {"key": "REFRESH_BUTTON", "label": "Sofort-Aktualisierung", "gruppe": "uebersicht",
"hinweis": "Der von der Integration bereitgestellte Knopf für \"Ziehen zum Aktualisieren\".", "hinweis": "Der von der Integration bereitgestellte Knopf für \"Ziehen zum Aktualisieren\".",
"domains": ["button"], "device_classes": [], "units": [], "liste": False, "pflicht": False, "domains": ["button"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"beispiel": "refresh_now",
"stichworte": ["refresh", "aktualisieren", "update", "abrufen", "poll"]}, "stichworte": ["refresh", "aktualisieren", "update", "abrufen", "poll"]},
{"key": "TUER_SENSOREN", "label": "Türen", "gruppe": "sicherheit", {"key": "TUER_SENSOREN", "label": "Türen", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu. Für \"Sicher abgestellt\" wichtig.", "hinweis": "\"aus\"/off = zu. Für \"Sicher abgestellt\" wichtig.",
"domains": ["binary_sensor"], "device_classes": ["door"], "units": [], "liste": True, "positionen": POSITIONEN, "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", {"key": "FENSTER_SENSOREN", "label": "Fenster", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.", "hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["window"], "units": [], "liste": True, "positionen": POSITIONEN, "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", {"key": "HECKKLAPPE_SENSOR", "label": "Heckklappe", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.", "hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False, "domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
"beispiel": "tailgate",
"stichworte": ["heckklappe", "tailgate", "kofferraum", "trunk"]}, "stichworte": ["heckklappe", "tailgate", "kofferraum", "trunk"]},
{"key": "HAUBE_SENSOR", "label": "Motorhaube", "gruppe": "sicherheit", {"key": "HAUBE_SENSOR", "label": "Motorhaube", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.", "hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False, "domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
"beispiel": "hood",
"stichworte": ["haube", "hood", "bonnet", "motorhaube"]}, "stichworte": ["haube", "hood", "bonnet", "motorhaube"]},
{"key": "TUERSCHLOSS_SENSOR", "label": "Türschloss", "gruppe": "sicherheit", {"key": "TUERSCHLOSS_SENSOR", "label": "Türschloss", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = verriegelt. Die Zentralverriegelung schließt alle Türen gemeinsam - " "hinweis": "\"aus\"/off = verriegelt. Die Zentralverriegelung schließt alle Türen gemeinsam - "
"die Fahrertür genügt für \"Fahrzeug verriegelt\".", "die Fahrertür genügt für \"Fahrzeug verriegelt\".",
"domains": ["binary_sensor"], "device_classes": ["lock"], "units": [], "liste": False, "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", {"key": "DACH_SENSOR", "label": "Schiebedach", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.", "hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["window", "door", "opening"], "units": [], "liste": False, "pflicht": False, "domains": ["binary_sensor"], "device_classes": ["window", "door", "opening"], "units": [], "liste": False, "pflicht": False,
"beispiel": "sunroof",
"stichworte": ["dach", "schiebedach", "sunroof", "roof"]}, "stichworte": ["dach", "schiebedach", "sunroof", "roof"]},
{"key": "LICHT_SENSOR", "label": "Standlicht", "gruppe": "sicherheit", {"key": "LICHT_SENSOR", "label": "Standlicht", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = kein Licht.", "hinweis": "\"aus\"/off = kein Licht.",
"domains": ["binary_sensor"], "device_classes": ["light"], "units": [], "liste": False, "pflicht": False, "domains": ["binary_sensor"], "device_classes": ["light"], "units": [], "liste": False, "pflicht": False,
"beispiel": "parking_lights",
"stichworte": ["licht", "standlicht", "light", "parking lights"]}, "stichworte": ["licht", "standlicht", "light", "parking lights"]},
{"key": "NAECHSTER_OELWECHSEL_SENSOR", "label": "Nächster Ölwechsel", "gruppe": "uebersicht", {"key": "NAECHSTER_OELWECHSEL_SENSOR", "label": "Nächster Ölwechsel", "gruppe": "uebersicht",
"hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit, ergänzt die App-eigene Servicebuch-Prognose.", "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, "domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False,
"beispiel": "oil_change_due",
"stichworte": ["ölwechsel", "oil", "service", "change"]}, "stichworte": ["ölwechsel", "oil", "service", "change"]},
{"key": "OELWECHSEL_STRECKE_SENSOR", "label": "Nächster Ölwechsel", "gruppe": "uebersicht", {"key": "OELWECHSEL_STRECKE_SENSOR", "label": "Nächster Ölwechsel", "gruppe": "uebersicht",
"hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.", "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, "domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"beispiel": "oil_change_distance",
"stichworte": ["ölwechsel", "oil", "distance", "strecke"]}, "stichworte": ["ölwechsel", "oil", "distance", "strecke"]},
{"key": "NAECHSTE_INSPEKTION_SENSOR", "label": "Nächste Inspektion", "gruppe": "uebersicht", {"key": "NAECHSTE_INSPEKTION_SENSOR", "label": "Nächste Inspektion", "gruppe": "uebersicht",
"hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit.", "hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit.",
"domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False, "domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False,
"beispiel": "inspection_due",
"stichworte": ["inspektion", "inspection", "service"]}, "stichworte": ["inspektion", "inspection", "service"]},
{"key": "INSPEKTION_STRECKE_SENSOR", "label": "Nächste Inspektion", "gruppe": "uebersicht", {"key": "INSPEKTION_STRECKE_SENSOR", "label": "Nächste Inspektion", "gruppe": "uebersicht",
"hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.", "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, "domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"beispiel": "inspection_distance",
"stichworte": ["inspektion", "inspection", "distance", "strecke"]}, "stichworte": ["inspektion", "inspection", "distance", "strecke"]},
{"key": "STANDORT_LAT_SENSOR", "label": "GPS-Standort: Breitengrad", "gruppe": "standort", {"key": "STANDORT_LAT_SENSOR", "label": "GPS-Standort: Breitengrad", "gruppe": "standort",
"hinweis": "Breitengrad als eigener Sensor (z. B. flespi).", "hinweis": "Breitengrad als eigener Sensor (z. B. flespi).",
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False, "domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"beispiel": "latitude_coordinate_value",
"stichworte": ["breitengrad", "latitude", "lat", "standort", "gps", "position", "ortung"]}, "stichworte": ["breitengrad", "latitude", "lat", "standort", "gps", "position", "ortung"]},
{"key": "STANDORT_LON_SENSOR", "label": "GPS-Standort: Längengrad", "gruppe": "standort", {"key": "STANDORT_LON_SENSOR", "label": "GPS-Standort: Längengrad", "gruppe": "standort",
"hinweis": "Längengrad als eigener Sensor (z. B. flespi).", "hinweis": "Längengrad als eigener Sensor (z. B. flespi).",
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False, "domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"beispiel": "longitude_coordinate_value",
"stichworte": ["längengrad", "longitude", "lon", "lng", "standort", "gps", "position", "ortung"]}, "stichworte": ["längengrad", "longitude", "lon", "lng", "standort", "gps", "position", "ortung"]},
] ]
@@ -1 +1 @@
{"version":"2026.8.30.20","sha256":"28839ee2ba8d1d703bb688a0124a2930ffb45b0d92acfc05d9c9504b7141c144","bytes":257018,"gebaut":"2026-08-30T22:05:13Z"} {"version":"2026.8.31.1","sha256":"bd990f600b5bf81ce66105af8d49a77d31a2a4e3b4e5070858c38e1e8d23fcc9","bytes":266016,"gebaut":"2026-08-31T12:23:41Z"}
@@ -573,6 +573,82 @@ function tiles() {
if (TILE) MAP.removeLayer(TILE); if (TILE) MAP.removeLayer(TILE);
TILE = window.L.tileLayer(url, { maxZoom: 19, attribution: ATTR }).addTo(MAP); TILE = window.L.tileLayer(url, { maxZoom: 19, attribution: ATTR }).addTo(MAP);
} }
/* Fahrtrichtung auf der Streckenlinie.
Bis 2026-08-31 lagen auf der Linie kleine runde Punkte - je einer pro
GPS-Stuetzpunkt. Sie sagten nichts ueber die Richtung, und bei einer Fahrt
hin und zurueck auf derselben Strasse verschmolzen Hin- und Rueckweg zu
einem einzigen roten Band: aus der Karte war nicht mehr abzulesen, dass es
ueberhaupt zwei Wege waren.
Die Form ist der Navigationspfeil, kein gleichschenkliges Dreieck: das
Dreieck sieht von beiden Seiten gleich aus und laesst auf Abstand offen,
wo vorn ist - besonders dort, wo die Strecke die Richtung wechselt. Die
Kerbe im Fuss macht Kopf und Ende unterscheidbar (Nutzervorgabe mit
Vorlage, 2026-08-31).
Drei Entscheidungen, jede an Mustern nachgemessen (siehe unten):
1. Abstand nach STRECKE, nicht nach Stuetzpunkten. Die Punktdichte des
Rohtracks haengt an der Geschwindigkeit - im Stau entsteht ein Haufen,
auf der Autobahn eine Luecke. Pfeile daran haetten genau dort geklumpt,
wo am wenigsten passiert ist.
2. Mittig auf der Linie, ohne Versatz quer zur Fahrtrichtung. Ein solcher
Versatz war erprobt und trennt eine Hin-und-Rueck-Strecke sauber auf
zwei Spuren - aber nur, wenn Hin- und Rueckweg wirklich Punkt fuer Punkt
uebereinanderliegen. Das tun echte GPS-Aufzeichnungen nicht (Eigentum
des Fahrzeughalters, 2026-08-31). Fuer einen Fall, der in den Daten
nicht vorkommt, waere die schiefe Lage aller Dreiecke ein taeglicher
Preis ohne Gegenwert.
3. Weisser Rand. Das Dreieck liegt in derselben Farbe auf derselben Linie;
ohne die helle Kontur verschwimmt beides zu einer dicken Wurst. */
function spurMeter(a, b) {
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, b) {
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(karte, spur, farbe) {
if (!Array.isArray(spur) || spur.length < 2 || !window.L) return;
const laengen = [];
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 = '<div style="transform:rotate(' + w + 'deg)">'
+ '<svg width="13" height="13" viewBox="0 0 10 10" style="overflow:visible">'
+ '<path d="M5 0.4 L8.8 9.6 L5 6.4 L1.2 9.6 Z" fill="none" stroke="#fff" stroke-width="1.8" stroke-linejoin="round" opacity=".95"/>'
+ '<path d="M5 0.4 L8.8 9.6 L5 6.4 L1.2 9.6 Z" fill="' + farbe + '"/></svg></div>';
window.L.marker([lat, lon], {
icon: window.L.divIcon({ className: "spur-pfeil", html, iconSize: [13, 13], iconAnchor: [6.5, 6.5] }),
interactive: false,
}).addTo(karte);
ziel += schritt;
}
gelaufen += d;
}
}
async function initMap() { async function initMap() {
await leafletLaden(); await leafletLaden();
const el = ROOT.getElementById("map"); const el = ROOT.getElementById("map");
@@ -591,7 +667,10 @@ async function initMap() {
? t.route ? t.route
: [[t.start_lat, t.start_lon], [t.end_lat, t.end_lon]]; : [[t.start_lat, t.start_lon], [t.end_lat, t.end_lon]];
const line = window.L.polyline(track, { color: rot, weight: 2.5, opacity: 0.9, dashArray: Array.isArray(t.route) && t.route.length >= 2 ? null : "4 6" }).addTo(MAP); const line = window.L.polyline(track, { color: rot, weight: 2.5, opacity: 0.9, dashArray: Array.isArray(t.route) && t.route.length >= 2 ? null : "4 6" }).addTo(MAP);
track.slice(1, -1).forEach((pt) => window.L.circleMarker(pt, { radius: 2.5, color: rot, weight: 0, fillColor: rot, fillOpacity: 0.85 }).addTo(MAP)); // 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.
if (Array.isArray(t.route) && t.route.length >= 2) spurPfeile(MAP, track, rot);
window.L.circleMarker([t.start_lat, t.start_lon], { radius: 6, color: "#fff", weight: 2, fillColor: "#000", fillOpacity: 1 }).addTo(MAP).bindPopup("Start · " + (t.start_address || "")); window.L.circleMarker([t.start_lat, t.start_lon], { radius: 6, color: "#fff", weight: 2, fillColor: "#000", fillOpacity: 1 }).addTo(MAP).bindPopup("Start · " + (t.start_address || ""));
window.L.circleMarker([t.end_lat, t.end_lon], { radius: 6, color: rot, weight: 2, fillColor: rot, fillOpacity: 1 }).addTo(MAP).bindPopup("Ziel · " + (t.end_address || "")); window.L.circleMarker([t.end_lat, t.end_lon], { radius: 6, color: rot, weight: 2, fillColor: rot, fillOpacity: 1 }).addTo(MAP).bindPopup("Ziel · " + (t.end_address || ""));
MAP.fitBounds(line.getBounds(), { padding: [26, 26] }); MAP.fitBounds(line.getBounds(), { padding: [26, 26] });
@@ -637,6 +716,7 @@ async function initMap() {
let USER_POS = null; // {lat, lon, genauigkeit} oder null let USER_POS = null; // {lat, lon, genauigkeit} oder null
let USER_POS_TS = 0; let USER_POS_TS = 0;
let USER_POS_FEHLER = null; let USER_POS_FEHLER = null;
let USER_WATCH = null; // watchPosition-Kennung, solange die Standortseite offen ist
let STANDORT_ADRESSE = null; // zuletzt aufgelöste Adresse (Cache über Koordinaten-Rundung) let STANDORT_ADRESSE = null; // zuletzt aufgelöste Adresse (Cache über Koordinaten-Rundung)
let STANDORT_ADRESSE_KEY = null; let STANDORT_ADRESSE_KEY = null;
let STANDORTMENU_OFFEN = false; let STANDORTMENU_OFFEN = false;
@@ -779,6 +859,43 @@ function standortErfassenFallsNoetig() {
standortErfassen(); standortErfassen();
} }
/* Fortlaufende Ortung, solange die Standortseite offen ist - "live bei
Verwendung". Die einmalige Abfrage oben bleibt daneben bestehen und ist
weiterhin das Richtige fuer die Uebersichtskachel: die zeigt nur einen
Abstand, und eine dauerhafte Hochgenauigkeits-Ortung auf der Startseite
waere Batterieverbrauch ohne Gegenwert.
Beendet wird die Verfolgung an denselben zwei Stellen, an denen auch SMAP
abgeraeumt wird - sonst liefe sie nach dem Verlassen der Seite unbemerkt
weiter. Genau das verlangt Apples Vorgabe fuer "Beim Verwenden der App":
erfassen, solange der Nutzer die Karte ansieht, und keine Sekunde laenger. */
function standortVerfolgungStarten() {
if (USER_WATCH !== null) return;
if (!navigator.geolocation) { USER_POS_FEHLER = "GPS offline"; standortAnsichtAktualisieren(); return; }
USER_WATCH = navigator.geolocation.watchPosition(
(pos) => {
USER_POS = { lat: pos.coords.latitude, lon: pos.coords.longitude, genauigkeit: pos.coords.accuracy };
USER_POS_TS = Date.now(); USER_POS_FEHLER = null;
standortAnsichtAktualisieren();
},
(err) => {
// Dieselbe Unterscheidung wie in standortErfassen(): die verweigerte
// Freigabe ist der einzige Fall mit einer anderen Abhilfe.
USER_POS_FEHLER = err.code === err.PERMISSION_DENIED ? "GPS-Freigabe fehlt" : "GPS offline";
USER_POS_TS = Date.now();
standortAnsichtAktualisieren();
},
// maximumAge klein: eine zwischengespeicherte Position aus der letzten
// halben Minute waere hier das Gegenteil von "live".
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 2000 }
);
}
function standortVerfolgungBeenden() {
if (USER_WATCH === null) return;
navigator.geolocation.clearWatch(USER_WATCH);
USER_WATCH = null;
}
/* Reverse-Geocoding über die öffentliche Nominatim-API (OpenStreetMap) - /* Reverse-Geocoding über die öffentliche Nominatim-API (OpenStreetMap) -
wie die Kartenkacheln oben (TILE_URL) ohne eigenen Schlüssel; bei diesem wie die Kartenkacheln oben (TILE_URL) ohne eigenen Schlüssel; bei diesem
Aufrufvolumen (ein Fahrzeug, nur bei geöffneter Standortansicht) im Rahmen Aufrufvolumen (ein Fahrzeug, nur bei geöffneter Standortansicht) im Rahmen
@@ -961,7 +1078,11 @@ function standortMarkerZeichnen() {
} }
function standortAufFahrzeug() { if (SMAP && standortBekannt()) SMAP.flyTo([CAR.standortLat, CAR.standortLon], Math.max(SMAP.getZoom(), 15)); } function standortAufFahrzeug() { if (SMAP && standortBekannt()) SMAP.flyTo([CAR.standortLat, CAR.standortLon], Math.max(SMAP.getZoom(), 15)); }
function standortAufUser() { function standortAufUser() {
if (!USER_POS) { standortErfassen(); return; } // Noch keine Position: die Verfolgung neu anstossen. Das ist zugleich der
// Weg zurueck, wenn die Freigabe erst nachtraeglich erteilt wurde - ein
// einmal gescheiterter watchPosition-Auftrag meldet sich nicht von selbst
// wieder.
if (!USER_POS) { standortVerfolgungBeenden(); standortVerfolgungStarten(); return; }
if (SMAP) SMAP.flyTo([USER_POS.lat, USER_POS.lon], Math.max(SMAP.getZoom(), 15)); if (SMAP) SMAP.flyTo([USER_POS.lat, USER_POS.lon], Math.max(SMAP.getZoom(), 15));
} }
function standortBeideZeigen() { function standortBeideZeigen() {
@@ -986,7 +1107,7 @@ async function initStandortMap() {
standortMarkerZeichnen(); standortMarkerZeichnen();
standortBeideZeigen(); standortBeideZeigen();
standortAdresseLaden(); standortAdresseLaden();
standortErfassenFallsNoetig(); standortVerfolgungStarten();
setTimeout(() => SMAP && SMAP.invalidateSize(), 60); setTimeout(() => SMAP && SMAP.invalidateSize(), 60);
} }
async function initVorschauMap() { async function initVorschauMap() {
@@ -2254,7 +2375,7 @@ function vIdent() {
<dl class="rows" style="margin-top:10px"> <dl class="rows" style="margin-top:10px">
<div class="row"><dt>Modell</dt><dd>${esc(CONFIG.fahrzeugtitel)}</dd></div> <div class="row"><dt>Modell</dt><dd>${esc(CONFIG.fahrzeugtitel)}</dd></div>
<div class="row"><dt>Ausführung</dt><dd>${CONFIG.ausfuehrung ? esc(CONFIG.ausfuehrung) : "noch offen"}</dd></div> <div class="row"><dt>Ausführung</dt><dd>${CONFIG.ausfuehrung ? esc(CONFIG.ausfuehrung) : "noch offen"}</dd></div>
<div class="row"><dt>Fahrgestellnummer</dt><dd>${esc(CAR.fin)}</dd></div> <div class="row"><dt>FIN</dt><dd>${esc(CAR.fin)}</dd></div>
<div class="row"><dt>Motor und Leistung</dt><dd>${esc(CAR.details)}</dd></div> <div class="row"><dt>Motor und Leistung</dt><dd>${esc(CAR.details)}</dd></div>
<div class="row"><dt>Erstzulassung</dt><dd>${esc(CAR.erstzulassung)}</dd></div> <div class="row"><dt>Erstzulassung</dt><dd>${esc(CAR.erstzulassung)}</dd></div>
<div class="row"><dt>Kennzeichen</dt><dd>${esc(CONFIG.kennzeichen)}</dd></div> <div class="row"><dt>Kennzeichen</dt><dd>${esc(CONFIG.kennzeichen)}</dd></div>
@@ -2763,7 +2884,7 @@ function vEinst() {
</div> </div>
<div class="tile"><span class="label">Kraftstoff-Rabatt</span> <div class="tile"><span class="label">Kraftstoff-Rabatt</span>
<div class="feld"><label for="sdAktiv" style="display:flex;align-items:center;gap:6px"> <div class="feld"><label for="sdAktiv" style="display:flex;align-items:center;gap:6px">
<img src="${STATIK}/bilder/${MARKENLOGO_DATEI}" alt="Shell" style="height:14px;width:auto" onerror="this.style.display='none'"> SmartDeal aktiv</label> <img src="${STATIK}/bilder/${MARKENLOGO_DATEI}" alt="Shell" style="height:16px;width:auto" onerror="this.style.display='none'"> SmartDeal aktiv</label>
<label class="switch"><input id="sdAktiv" type="checkbox" data-sd ${CAR.smartdeal.aktiv ? "checked" : ""}><span class="track"></span></label></div> <label class="switch"><input id="sdAktiv" type="checkbox" data-sd ${CAR.smartdeal.aktiv ? "checked" : ""}><span class="track"></span></label></div>
${CAR.smartdeal.aktiv ? ` ${CAR.smartdeal.aktiv ? `
<div class="feld" style="border-bottom:0"><label>Gültig bis</label> <div class="feld" style="border-bottom:0"><label>Gültig bis</label>
@@ -3897,7 +4018,10 @@ const ICONS = {
/* Wagen von vorn - dasselbe Symbol wie in der myAudi-App (Audi-Symbolsatz, /* Wagen von vorn - dasselbe Symbol wie in der myAudi-App (Audi-Symbolsatz,
assets/icons/car.svg). Flaeche statt Kontur, deshalb die Klasse "voll". */ assets/icons/car.svg). Flaeche statt Kontur, deshalb die Klasse "voll". */
audi: '<path d="M5.46 4.05a80 80 0 0 1 13.08 0l.24.02.14.2A55 55 0 0 1 21.9 9.2l2.1 2.1v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V19l-14-.04v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 19.5v-8.2l2.1-2.1a55 55 0 0 1 2.98-4.93l.14-.2zM2.7 10 1 11.7v.3h4v1H1v6.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.55L20 18v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V13h-4v-1h4v-.3L21.3 10zm17.94-1a45 45 0 0 0-2.43-3.98 78 78 0 0 0-12.44 0L4.51 7c-.3.5-.7 1.16-1.16 2.01h17.3ZM7.3 12h9.42l1.36 1.36-.1.3c-.31 1-.6 1.78-.88 2.31-.13.27-.27.5-.41.67-.13.15-.36.36-.68.36H8a.9.9 0 0 1-.68-.36c-.14-.17-.28-.4-.41-.67a17 17 0 0 1-.89-2.32l-.09-.29zm.42 1-.64.64c.28.84.52 1.47.73 1.89a2.4 2.4 0 0 0 .3.47h7.8l.02-.01c.07-.08.16-.23.28-.46.21-.42.45-1.05.73-1.9L16.3 13H7.71Z"/>', audi: '<path d="M5.46 4.05a80 80 0 0 1 13.08 0l.24.02.14.2A55 55 0 0 1 21.9 9.2l2.1 2.1v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V19l-14-.04v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 19.5v-8.2l2.1-2.1a55 55 0 0 1 2.98-4.93l.14-.2zM2.7 10 1 11.7v.3h4v1H1v6.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.55L20 18v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V13h-4v-1h4v-.3L21.3 10zm17.94-1a45 45 0 0 0-2.43-3.98 78 78 0 0 0-12.44 0L4.51 7c-.3.5-.7 1.16-1.16 2.01h17.3ZM7.3 12h9.42l1.36 1.36-.1.3c-.31 1-.6 1.78-.88 2.31-.13.27-.27.5-.41.67-.13.15-.36.36-.68.36H8a.9.9 0 0 1-.68-.36c-.14-.17-.28-.4-.41-.67a17 17 0 0 1-.89-2.32l-.09-.29zm.42 1-.64.64c.28.84.52 1.47.73 1.89a2.4 2.4 0 0 0 .3.47h7.8l.02-.01c.07-.08.16-.23.28-.46.21-.42.45-1.05.73-1.9L16.3 13H7.71Z"/>',
trips: '<circle cx="6" cy="6" r="2.2"/><circle cx="18" cy="18" r="2.2"/><path d="M8 7.5c4 1.5 4.5 3 3 5s-1 3.5 4.5 5"/>', /* CI-Symbol tour-s statt der frueheren handgezeichneten Route - dieselbe
Fuellflaeche-statt-Kontur-Behandlung wie "audi" und "stat" (siehe
ICONS_VOLL). */
trips: '<path d="M3.4 3C2.6 3 2 3.64 2 4.5c0 .16.08.46.27.89a15.5 15.5 0 0 0 1.23 2.2 18.4 18.4 0 0 0 1.23-2.2c.19-.43.27-.73.27-.89C5 3.68 4.32 3 3.5 3zm.1 5.5-.41.28-.03-.03a18 18 0 0 1-.3-.45l-.7-1.14a14 14 0 0 1-.7-1.37A3.5 3.5 0 0 1 1 4.5C1 3.16 2 2 3.4 2h.1A2.5 2.5 0 0 1 6 4.5c0 .39-.17.86-.35 1.29a27 27 0 0 1-1.71 2.96l-.02.02v.01zm0 0 .41.28-.41.6-.41-.6zM12.4 3c-.8 0-1.4.64-1.4 1.5 0 .16.08.46.27.89a15.5 15.5 0 0 0 1.23 2.2 18.4 18.4 0 0 0 1.23-2.2c.19-.43.27-.73.27-.89 0-.82-.68-1.5-1.5-1.5zm.1 5.5-.41.28-.03-.03a12 12 0 0 1-.3-.45l-.7-1.14c-.25-.44-.5-.92-.7-1.37A3.5 3.5 0 0 1 10 4.5C10 3.16 11 2 12.4 2h.1A2.5 2.5 0 0 1 15 4.5c0 .39-.17.86-.35 1.29a27 27 0 0 1-1.71 2.96l-.02.02v.01zm0 0 .41.28-.41.6-.41-.6zm5.79-.5h.21a4.5 4.5 0 0 1 4.5 4.5c0 .66-.3 1.52-.67 2.38-.38.88-.9 1.83-1.4 2.7a54 54 0 0 1-1.97 3.14l-.04.05v.01l-.42-.28-.41.28-.01-.01-.04-.05a40 40 0 0 1-.58-.89 33 33 0 0 1-2.8-4.95A6.6 6.6 0 0 1 14 12.5 4.34 4.34 0 0 1 18.28 8Zm.21 12.5-.41.28.41.6.41-.6zm0-.9a53 53 0 0 0 1.57-2.53c.5-.85.98-1.76 1.35-2.6s.58-1.53.58-1.97c0-1.92-1.58-3.5-3.5-3.5h-.19c-1.94.1-3.4 1.58-3.31 3.48v.02c0 .44.2 1.13.58 1.98.37.83.86 1.74 1.35 2.6a53 53 0 0 0 1.57 2.52M9.94 11H3v-1h10.06l-7.61 5.44 13.29 7.12-.48.88-14.7-7.88L9.93 11Z"/>',
/* CI-Symbol polls-s statt der vorherigen handgezeichneten Balken - dieselbe /* CI-Symbol polls-s statt der vorherigen handgezeichneten Balken - dieselbe
Fuellflaeche-statt-Kontur-Behandlung wie "audi" (siehe ICONS_VOLL). Anders Fuellflaeche-statt-Kontur-Behandlung wie "audi" (siehe ICONS_VOLL). Anders
als die handgezeichneten Tab-Icons (die im 24er-Raster deutlich Rand als die handgezeichneten Tab-Icons (die im 24er-Raster deutlich Rand
@@ -3920,7 +4044,7 @@ const TABS = [
{ id: "fuel", label: "Tanken", eyebrow: "", title: "Tanken" }, { id: "fuel", label: "Tanken", eyebrow: "", title: "Tanken" },
]; ];
/* Symbole, die als Flaeche gezeichnet sind statt als Kontur */ /* Symbole, die als Flaeche gezeichnet sind statt als Kontur */
const ICONS_VOLL = new Set(["audi", "stat"]); const ICONS_VOLL = new Set(["audi", "stat", "trips"]);
let route = { name: "home" }; let route = { name: "home" };
const ZURUECK = { const ZURUECK = {
trip: "trips", fill: "fuel", service: "audi", reifen: "audi", ident: "audi", trip: "trips", fill: "fuel", service: "audi", reifen: "audi", ident: "audi",
@@ -4286,77 +4410,17 @@ const LUPE_SVG = `<svg viewBox="0 0 20 20" width="16" height="16" fill="none" st
/* Gemeinsames Präfix zweier domain-freier IDs, bis zum letzten gemeinsamen /* Gemeinsames Präfix zweier domain-freier IDs, bis zum letzten gemeinsamen
"_" - oder null, wenn sie nicht einmal ein volles Segment teilen. */ "_" - oder null, wenn sie nicht einmal ein volles Segment teilen. */
function praefixPaar(a, b) { /* praefixPaar()/setupAndereIdsOhneDomain()/entitaetIdKurz() sind am 2026-08-31
let i = 0; entfallen: sie kürzten die Kennung des gerade zugeordneten Sensors für den
while (i < a.length && i < b.length && a[i].toLowerCase() === b[i].toLowerCase()) i++; Beispieltext im Setup. Der kommt jetzt fest aus dem Feld-Katalog, damit er
const idxUnter = a.slice(0, i).lastIndexOf("_"); sich nicht mehr mit jeder Auswahl ändert - siehe setupFeldzeile(). */
return idxUnter > 0 ? a.slice(0, idxUnter + 1) : null;
}
/* Alle aktuell im Setup zugeordneten Entity-IDs (domain-frei), außer der
übergebenen selbst - Vergleichsbasis für entitaetIdKurz() unten. */
function setupAndereIdsOhneDomain(ausser) {
const ids = [];
for (const feld of setupKatalog()) {
const wert = setupZuordnung[feld.key];
if (feld.liste) { for (const w of (wert || [])) if (w && w !== ausser) ids.push(w); }
else if (wert && wert !== ausser) ids.push(wert);
}
return ids.map((id) => id.includes(".") ? id.split(".").slice(1).join(".") : id);
}
/* Die Entity-ID ohne Domain und ohne das installationsspezifische Präfix -
die tatsächliche, stabile Kennung des zugeordneten Sensors, unabhängig
davon, wie Geräte-Slug und Integrationsinstanz beim jeweiligen Nutzer
heißen (z. B. "testcar_b9_fmm003_testintegratoin_" oder
"audi_rs_4_avant_").
Das Präfix ist NICHT das mit dem einzelnen nächsten Verwandten geteilte
(frühere Fassung) - das kürzte in der Praxis zu viel: zwei Felder
derselben Sensorquelle können selbst wieder ein gemeinsames Namens-
Teilstück haben, das nichts mit der Installation zu tun hat (z. B. teilen
sich "oil_change_due" und "oil_change_distance" das Wortstück
"oil_change_", "can_fuel_volume" teilt sich "can_" mit einem anderen
CAN-Sensor) - genau das hat der Nutzer live beobachtet: "can_fuel_volume"
wurde zu bloß "fuel_volume", "oil_change_due" zu bloß "due". Stattdessen
zählt jedes Präfix, das eine Entität mit irgendeiner ANDEREN teilt, als
eine Stimme; das Präfix mit den MEISTEN Stimmen gewinnt. Das echte
Installationspräfix teilt sich die Entität mit fast allen anderen
Sensoren ihrer Quelle (viele Stimmen), ein zufällig geteiltes
Namens-Teilstück nur mit dem einen verwandten Feld (eine Stimme) - die
Mehrheit entscheidet also richtig, ohne einen einzelnen "nächsten"
Nachbarn zu bevorzugen. Bei Gleichstand gewinnt das kürzere Präfix
(vorsichtiger - weniger Kürzung im Zweifel). Mehrere gleichzeitige
Sensorquellen (z. B. FMM003 und eine zweite, "audi_rs_4_avant_"-
präfixte Integration) bleiben dabei weiterhin unabhängig voneinander
korrekt gekürzt, da die Stimmen pro Entität und nicht global gezählt
werden. Findet sich kein Verwandter mit gemeinsamem Präfix, bleibt die
volle domain-freie ID stehen statt einer geratenen, möglicherweise
falschen Kürzung. */
function entitaetIdKurz(entityId) {
if (!entityId) return null;
const ohneDomain = entityId.includes(".") ? entityId.split(".").slice(1).join(".") : entityId;
const stimmen = new Map();
for (const andere of setupAndereIdsOhneDomain(entityId)) {
const kandidat = praefixPaar(ohneDomain, andere);
if (!kandidat) continue;
stimmen.set(kandidat, (stimmen.get(kandidat) || 0) + 1);
}
let bestesPraefix = null, besteStimmen = 0;
for (const [kandidat, anzahl] of stimmen) {
const besser = anzahl > besteStimmen || (anzahl === besteStimmen && bestesPraefix && kandidat.length < bestesPraefix.length);
if (besser) { bestesPraefix = kandidat; besteStimmen = anzahl; }
}
return bestesPraefix ? ohneDomain.slice(bestesPraefix.length) : ohneDomain;
}
/* Die Art des erwarteten Werts, als kurzes Klammer-Etikett direkt neben der /* Die Art des erwarteten Werts, als kurzes Klammer-Etikett direkt neben der
Feld-Überschrift ("Tankfüllstand [%]", "12V-Batteriespannung [V]") - eine Feld-Überschrift ("Tankfüllstand [%]", "12V-Batteriespannung [V]") - eine
feste Eigenschaft der FELD-ROLLE selbst (aus dem FELDER-Katalog, feste Eigenschaft der FELD-ROLLE selbst (aus dem FELDER-Katalog,
einstellungen.py), unabhängig davon, ob und welcher Sensor gerade einstellungen.py), unabhängig davon, ob und welcher Sensor gerade
zugeordnet ist. Bewusst NICHT der Live-Wert eines Sensors - der gehört zugeordnet ist. Bewusst NICHT der Live-Wert eines Sensors - der gehört
nirgends mehr hin, siehe entitaetIdKurz()'s eigenen Kommentar zur nirgends mehr hin. binary_sensor liefert immer den Rohzustand "on"/"off"
Kopfzeile. binary_sensor liefert immer den Rohzustand "on"/"off"
(unabhängig von device_class - die beeinflusst nur, wie ANDERE (unabhängig von device_class - die beeinflusst nur, wie ANDERE
HA-Oberflächen ihn anzeigen, nicht den Rohwert, den diese App liest). HA-Oberflächen ihn anzeigen, nicht den Rohwert, den diese App liest).
Bei mehreren möglichen Einheiten (z. B. "L"/"l") die klein geschriebene Bei mehreren möglichen Einheiten (z. B. "L"/"l") die klein geschriebene
@@ -4409,18 +4473,33 @@ function entitaetCombo(key, idx) {
} }
function setupFeldzeile(feld) { function setupFeldzeile(feld) {
// Bei einem Listenfeld (vier Positionen wie Tür-/Fenstersensoren) gibt es // Einheit und Beispiel gelten beide für die ganze Feld-Rolle und stehen
// keinen EINEN zugeordneten Sensor, dessen Kennung sich anzeigen ließe - // deshalb auch bei Listenfeldern (vier Positionen wie Tür-/Fenstersensoren).
// jede Position hat ihre eigene Zuordnung, siehe die Unterfelder unten.
// Das Einheiten-Etikett neben dem Label gilt dagegen für die ganze Rolle
// und wird auch dort gezeigt.
const einheit = feldEinheitAnzeige(feld); const einheit = feldEinheitAnzeige(feld);
const erwartet = feld.liste ? "" : entitaetIdKurz(setupZuordnung[feld.key]); /* Das Beispiel steht fest im Feld-Katalog (FELDER in einstellungen.py,
// Nutzerkorrektur 2026-08-28: kein erfundenes statisches Beispiel mehr (war Schlüssel "beispiel") und gehört damit zur FELD-ROLLE - genau wie das
// für Reichweite/Sofort-Aktualisierung schlicht falsch) - nur EINE Zeile, Einheiten-Etikett daneben.
// der tatsächlich zugeordnete Sensor (erwartet), als "(z. B. ...)"
// dargestellt. Der Klammertext selbst in Label-Größe/-Farbe, der Sensorname Vorgeschichte in zwei Schritten:
// darin in derselben kleinen/grauen Schrift wie die Einheit ("[on/off]").
Bis 2026-08-28 stand hier ein von Hand ausgedachtes Beispiel. Das war
für Reichweite und Sofort-Aktualisierung schlicht falsch, und der
Nutzer hat es zu Recht beanstandet. Der Ersatz - die gekürzte Kennung
des GERADE ZUGEORDNETEN Sensors - war zwar nie falsch, aber auch kein
Beispiel mehr: er änderte sich mit jeder Auswahl mit und sagte einem
damit nur zurück, was man eben selbst eingestellt hatte (Nutzerkorrektur
2026-08-31: "Ich möchte, dass xxx permanent bleibt").
Jetzt wieder fest - aber nicht ausgedacht: die Namen sind die der
tatsächlich laufenden Integration, abgelesen aus der Zuordnung dieser
Installation. Damit ist das Beispiel stabil UND wahr, und beide
Beanstandungen sind zugleich erfüllt.
Listenfelder bekommen es ebenfalls: dort ist der Name der ersten
Position das Muster, nach dem sich die anderen drei richten. */
const erwartet = feld.beispiel || "";
// Der Klammertext in Label-Größe/-Farbe, der Sensorname darin in derselben
// kleinen/grauen Schrift wie die Einheit ("[on/off]").
const hinweis = erwartet const hinweis = erwartet
? `<span class="setup-feld-hinweis">(z. B. <span class="setup-feld-einheit">${esc(erwartet)}</span>)</span>` ? `<span class="setup-feld-hinweis">(z. B. <span class="setup-feld-einheit">${esc(erwartet)}</span>)</span>`
: ""; : "";
@@ -4565,6 +4644,7 @@ function render() {
Aenderungspruefung in datenLaden(). */ Aenderungspruefung in datenLaden(). */
if (MAP) { MAP.remove(); MAP = null; TILE = null; } if (MAP) { MAP.remove(); MAP = null; TILE = null; }
if (route.name === "trip" || route.name === "fill") initMap().catch(() => {}); // ohne Internet bleibt die Kartenflaeche leer if (route.name === "trip" || route.name === "fill") initMap().catch(() => {}); // ohne Internet bleibt die Kartenflaeche leer
standortVerfolgungBeenden();
if (SMAP) { SMAP.remove(); SMAP = null; SMAP_TILE = null; FAHRZEUG_MARKER = null; USER_MARKER = null; } if (SMAP) { SMAP.remove(); SMAP = null; SMAP_TILE = null; FAHRZEUG_MARKER = null; USER_MARKER = null; }
if (route.name === "standort") initStandortMap().catch(() => {}); if (route.name === "standort") initStandortMap().catch(() => {});
if (TMAP) { TMAP.remove(); TMAP = null; TMAP_TILE = null; } if (TMAP) { TMAP.remove(); TMAP = null; TMAP_TILE = null; }
@@ -5953,6 +6033,7 @@ class AudiDashboardPanel extends HTMLElement {
// (Standort- oder Uebersichtsseite offen) eine Leaflet-Instanz samt ihrem // (Standort- oder Uebersichtsseite offen) eine Leaflet-Instanz samt ihrem
// window-Resize-Horcher und laufenden Kachelabrufen am Leben. // window-Resize-Horcher und laufenden Kachelabrufen am Leben.
if (MAP) { MAP.remove(); MAP = null; TILE = null; } if (MAP) { MAP.remove(); MAP = null; TILE = null; }
standortVerfolgungBeenden();
if (SMAP) { SMAP.remove(); SMAP = null; SMAP_TILE = null; FAHRZEUG_MARKER = null; USER_MARKER = null; } if (SMAP) { SMAP.remove(); SMAP = null; SMAP_TILE = null; FAHRZEUG_MARKER = null; USER_MARKER = null; }
if (TMAP) { TMAP.remove(); TMAP = null; TMAP_TILE = null; } if (TMAP) { TMAP.remove(); TMAP = null; TMAP_TILE = null; }
} }
@@ -5992,6 +6073,7 @@ class AudiDashboardPanel extends HTMLElement {
<button class="back" id="back" aria-label="Zurück"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="m10.21 12 5.12-5.08-.7-.71L8.79 12l5.84 5.8.7-.72L10.22 12Z"/></svg></button> <button type="button" class="rings" id="marke" aria-label="Einstellungen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 289 100" fill="currentColor" aria-hidden="true"><defs><clipPath id="audiRingsClip" clipPathUnits="userSpaceOnUse"><path d="M0 100h289V0H0z"/></clipPath></defs><g clip-path="url(#audiRingsClip)" transform="matrix(1 0 0 -1 0 100.00040017)"><path d="M239 11.1914c-8.885 0-17.075 3.008-23.624 8.05 6.646 8.489 10.624 19.165 10.624 30.759 0 11.593-3.978 22.269-10.624 30.759 6.549 5.041 14.739 8.05 23.624 8.05 21.399 0 38.809-17.41 38.809-38.809 0-21.4-17.41-38.809-38.809-38.809m-86.625 8.05c6.647 8.489 10.625 19.165 10.625 30.759 0 11.593-3.978 22.269-10.624 30.759 6.548 5.041 14.74 8.05 23.624 8.05 8.885 0 17.075-3.009 23.624-8.05-6.646-8.49-10.624-19.166-10.624-30.759 0-11.594 3.978-22.27 10.624-30.759-6.549-5.042-14.739-8.05-23.624-8.05-8.884 0-17.076 3.008-23.625 8.05m-63 0C96.022 27.7304 100 38.4064 100 50.0004c0 11.593-3.978 22.269-10.624 30.759 6.548 5.041 14.74 8.05 23.624 8.05 8.884 0 17.076-3.009 23.624-8.05-6.646-8.49-10.624-19.166-10.624-30.759 0-11.594 3.978-22.27 10.624-30.759-6.548-5.042-14.74-8.05-23.624-8.05-8.884 0-17.076 3.008-23.625 8.05m-78.184 30.759c0 21.399 17.41 38.809 38.809 38.809 8.884 0 17.076-3.009 23.624-8.05C66.978 72.2694 63 61.5934 63 50.0004c0-11.594 3.978-22.27 10.624-30.759-6.548-5.042-14.74-8.05-23.624-8.05-21.399 0-38.809 17.409-38.809 38.809M81.5 27.3734c-4.593 6.375-7.309 14.187-7.309 22.627 0 8.439 2.716 16.252 7.309 22.627 4.593-6.375 7.309-14.188 7.309-22.627 0-8.44-2.716-16.252-7.309-22.627m63 0c-4.593 6.375-7.309 14.187-7.309 22.627 0 8.439 2.716 16.252 7.309 22.627 4.593-6.375 7.309-14.188 7.309-22.627 0-8.44-2.716-16.252-7.309-22.627m63 0c-4.593 6.375-7.309 14.187-7.309 22.627 0 8.439 2.716 16.252 7.309 22.627 4.593-6.375 7.309-14.188 7.309-22.627 0-8.44-2.716-16.252-7.309-22.627m31.5 72.627c-11.932 0-22.896-4.207-31.5-11.207-8.604 7-19.568 11.207-31.5 11.207-11.932 0-22.896-4.207-31.5-11.207-8.604 7-19.569 11.207-31.5 11.207-11.932 0-22.896-4.207-31.5-11.207-8.604 7-19.569 11.207-31.5 11.207-27.57 0-50-22.43-50-50 0-27.571 22.43-50 50-50 11.931 0 22.896 4.206 31.5 11.207C90.104 4.2064 101.068.0004 113 .0004c11.931 0 22.896 4.206 31.5 11.207 8.604-7.001 19.568-11.207 31.5-11.207 11.932 0 22.896 4.206 31.5 11.207 8.604-7.001 19.568-11.207 31.5-11.207 27.57 0 50 22.429 50 50 0 27.57-22.43 50-50 50"/></g></svg></button> <button class="back" id="back" aria-label="Zurück"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="m10.21 12 5.12-5.08-.7-.71L8.79 12l5.84 5.8.7-.72L10.22 12Z"/></svg></button> <button type="button" class="rings" id="marke" aria-label="Einstellungen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 289 100" fill="currentColor" aria-hidden="true"><defs><clipPath id="audiRingsClip" clipPathUnits="userSpaceOnUse"><path d="M0 100h289V0H0z"/></clipPath></defs><g clip-path="url(#audiRingsClip)" transform="matrix(1 0 0 -1 0 100.00040017)"><path d="M239 11.1914c-8.885 0-17.075 3.008-23.624 8.05 6.646 8.489 10.624 19.165 10.624 30.759 0 11.593-3.978 22.269-10.624 30.759 6.549 5.041 14.739 8.05 23.624 8.05 21.399 0 38.809-17.41 38.809-38.809 0-21.4-17.41-38.809-38.809-38.809m-86.625 8.05c6.647 8.489 10.625 19.165 10.625 30.759 0 11.593-3.978 22.269-10.624 30.759 6.548 5.041 14.74 8.05 23.624 8.05 8.885 0 17.075-3.009 23.624-8.05-6.646-8.49-10.624-19.166-10.624-30.759 0-11.594 3.978-22.27 10.624-30.759-6.549-5.042-14.739-8.05-23.624-8.05-8.884 0-17.076 3.008-23.625 8.05m-63 0C96.022 27.7304 100 38.4064 100 50.0004c0 11.593-3.978 22.269-10.624 30.759 6.548 5.041 14.74 8.05 23.624 8.05 8.884 0 17.076-3.009 23.624-8.05-6.646-8.49-10.624-19.166-10.624-30.759 0-11.594 3.978-22.27 10.624-30.759-6.548-5.042-14.74-8.05-23.624-8.05-8.884 0-17.076 3.008-23.625 8.05m-78.184 30.759c0 21.399 17.41 38.809 38.809 38.809 8.884 0 17.076-3.009 23.624-8.05C66.978 72.2694 63 61.5934 63 50.0004c0-11.594 3.978-22.27 10.624-30.759-6.548-5.042-14.74-8.05-23.624-8.05-21.399 0-38.809 17.409-38.809 38.809M81.5 27.3734c-4.593 6.375-7.309 14.187-7.309 22.627 0 8.439 2.716 16.252 7.309 22.627 4.593-6.375 7.309-14.188 7.309-22.627 0-8.44-2.716-16.252-7.309-22.627m63 0c-4.593 6.375-7.309 14.187-7.309 22.627 0 8.439 2.716 16.252 7.309 22.627 4.593-6.375 7.309-14.188 7.309-22.627 0-8.44-2.716-16.252-7.309-22.627m63 0c-4.593 6.375-7.309 14.187-7.309 22.627 0 8.439 2.716 16.252 7.309 22.627 4.593-6.375 7.309-14.188 7.309-22.627 0-8.44-2.716-16.252-7.309-22.627m31.5 72.627c-11.932 0-22.896-4.207-31.5-11.207-8.604 7-19.568 11.207-31.5 11.207-11.932 0-22.896-4.207-31.5-11.207-8.604 7-19.569 11.207-31.5 11.207-11.932 0-22.896-4.207-31.5-11.207-8.604 7-19.569 11.207-31.5 11.207-27.57 0-50-22.43-50-50 0-27.571 22.43-50 50-50 11.931 0 22.896 4.206 31.5 11.207C90.104 4.2064 101.068.0004 113 .0004c11.931 0 22.896 4.206 31.5 11.207 8.604-7.001 19.568-11.207 31.5-11.207 11.932 0 22.896 4.206 31.5 11.207 8.604-7.001 19.568-11.207 31.5-11.207 27.57 0 50 22.429 50 50 0 27.57-22.43 50-50 50"/></g></svg></button>
<div class="head"><div class="eyebrow" id="eyebrow"></div><div class="title" id="title">Übersicht</div> <div class="head"><div class="eyebrow" id="eyebrow"></div><div class="title" id="title">Übersicht</div>
<div class="sync" id="sync"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="m18.65 4.95 2.56 2.55-2.56 2.55-.7-.7L19.29 8H7.2A5.24 5.24 0 0 0 2 13.38V14H1v-.6A6.24 6.24 0 0 1 7.2 7h12.1l-1.34-1.35.7-.7ZM23 11.59A6.24 6.24 0 0 1 16.8 18H4.7l1.35 1.35-.7.7-2.56-2.55 2.56-2.55.7.7L4.71 17H16.8a5.24 5.24 0 0 0 5.2-5.38V11h1v.6Z"/></svg><span></span></div></div> <div class="sync" id="sync"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="m18.65 4.95 2.56 2.55-2.56 2.55-.7-.7L19.29 8H7.2A5.24 5.24 0 0 0 2 13.38V14H1v-.6A6.24 6.24 0 0 1 7.2 7h12.1l-1.34-1.35.7-.7ZM23 11.59A6.24 6.24 0 0 1 16.8 18H4.7l1.35 1.35-.7.7-2.56-2.55 2.56-2.55.7.7L4.71 17H16.8a5.24 5.24 0 0 0 5.2-5.38V11h1v.6Z"/></svg><span></span></div></div>
<span class="kopfgegengewicht" aria-hidden="true"></span>
<button class="themebtn" id="theme" hidden aria-hidden="true"><svg id="themeicon" viewBox="0 0 20 20" width="15" height="15"></svg></button> <button class="themebtn" id="theme" hidden aria-hidden="true"><svg id="themeicon" viewBox="0 0 20 20" width="15" height="15"></svg></button>
<button class="profilbtn" id="ringsbtn" aria-label="Einstellungen"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 0 0 .33 1.76l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.6 1.6 0 0 0-1.76-.33 1.6 1.6 0 0 0-1 1.47V21a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-1-1.47 1.6 1.6 0 0 0-1.77.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.6 1.6 0 0 0 4.6 15a1.6 1.6 0 0 0-1.47-1H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.47-1 1.6 1.6 0 0 0-.33-1.77l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.6 1.6 0 0 0 9 4.6 1.6 1.6 0 0 0 10 3.13V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 1 1.47 1.6 1.6 0 0 0 1.77-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.6 1.6 0 0 0 19.4 9v.04a1.6 1.6 0 0 0 1.47 1H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1Z"/></svg></button> <button class="profilbtn" id="ringsbtn" aria-label="Einstellungen"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 0 0 .33 1.76l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.6 1.6 0 0 0-1.76-.33 1.6 1.6 0 0 0-1 1.47V21a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-1-1.47 1.6 1.6 0 0 0-1.77.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.6 1.6 0 0 0 4.6 15a1.6 1.6 0 0 0-1.47-1H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.47-1 1.6 1.6 0 0 0-.33-1.77l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.6 1.6 0 0 0 9 4.6 1.6 1.6 0 0 0 10 3.13V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 1 1.47 1.6 1.6 0 0 0 1.77-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.6 1.6 0 0 0 19.4 9v.04a1.6 1.6 0 0 0 1.47 1H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1Z"/></svg></button>
</div> </div>
@@ -105,6 +105,19 @@ html,body{height:100%}
.rings svg{height:17px} .rings svg{height:17px}
.profilbtn{display:none} .profilbtn{display:none}
/* Unterseite: kompakter, zentrierter Titel wie in einer iOS-Navigationsleiste */ /* Unterseite: kompakter, zentrierter Titel wie in einer iOS-Navigationsleiste */
/* Gegengewicht zum Zurueck-Pfeil auf Unterseiten.
Der Titel wird dort mittig gesetzt (.head{align-items:center}), aber nur
innerhalb von .head - und .head beginnt erst hinter dem Zurueck-Pfeil.
Rechts steht nichts, weil die Ringe auf Unterseiten ausgeblendet werden.
Die Ueberschrift sass deshalb um die halbe Pfeilbreite zu weit rechts;
in der App an derselben Stelle live gemessen: 17px (2026-08-31).
Gleiche Masse wie .back (34px, margin -8px), damit links und rechts
exakt derselbe Platz belegt ist. Auf breiten Bildschirmen steht der
Titel linksbuendig - dort ist der Platzhalter abgeschaltet. */
.kopfgegengewicht{display:none}
.phone:has(.back.on) .kopfgegengewicht{display:block;order:10;flex:none;width:34px;height:34px;margin-right:-8px}
.phone:has(.back.on) .topbar{padding:54px 16px 6px;gap:8px} .phone:has(.back.on) .topbar{padding:54px 16px 6px;gap:8px}
.phone:has(.back.on) .head{align-items:center} .phone:has(.back.on) .head{align-items:center}
.phone:has(.back.on) .title{font-size:17px;font-weight:400;letter-spacing:-.01em;text-align:center} .phone:has(.back.on) .title{font-size:17px;font-weight:400;letter-spacing:-.01em;text-align:center}
@@ -217,11 +230,19 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
.legend span{font-size:12.5px} .legend span{font-size:12.5px}
/* ---------------- Menüs und Popups ---------------- */ /* ---------------- Menüs und Popups ---------------- */
.bildmenu,.sdpopup{border:0;border-radius:14px;background:rgba(40,44,52,.92); /* Blaetter statt schwebender Karten (siehe audi-dashboard.css). Die Radien
folgen der neuen Lage: das Aktionsblatt steht frei ueber der unteren
Kante und ist rundum rund, das Formularblatt sitzt auf der Kante auf und
ist nur oben rund. */
.bildmenu{border:0;border-radius:16px;background:rgba(40,44,52,.92);
-webkit-backdrop-filter:saturate(180%) blur(22px);backdrop-filter:saturate(180%) blur(22px); -webkit-backdrop-filter:saturate(180%) blur(22px);backdrop-filter:saturate(180%) blur(22px);
box-shadow:0 12px 40px rgba(0,0,0,.5)} box-shadow:0 12px 40px rgba(0,0,0,.5)}
.sdpopup{border:0;border-radius:16px 16px 0 0;background:rgba(40,44,52,.92);
-webkit-backdrop-filter:saturate(180%) blur(22px);backdrop-filter:saturate(180%) blur(22px);
box-shadow:0 -8px 40px rgba(0,0,0,.5)}
:host([data-theme="tag"]) .bildmenu,:host([data-theme="tag"]) .sdpopup{background:rgba(250,250,252,.92)} :host([data-theme="tag"]) .bildmenu,:host([data-theme="tag"]) .sdpopup{background:rgba(250,250,252,.92)}
.bildmenu button{padding:13px 16px;font-size:16px;border-bottom:1px solid var(--ios-sep)} .bildmenu button{padding:16px 18px;font-size:16px;min-height:52px;border-bottom:1px solid var(--ios-sep)}
.bildmenu button:last-child{border-bottom:none}
/* ---------------- Tab-Leiste ---------------- */ /* ---------------- Tab-Leiste ---------------- */
.tabbar{border-top:1px solid var(--ios-sep);padding-bottom:26px; .tabbar{border-top:1px solid var(--ios-sep);padding-bottom:26px;
@@ -319,6 +340,7 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
/* Die Kopf-Ringe setzt die App per Inline-Stil (display:inline-flex), /* Die Kopf-Ringe setzt die App per Inline-Stil (display:inline-flex),
deshalb hier mit !important. */ deshalb hier mit !important. */
.rings{display:none !important} .rings{display:none !important}
.kopfgegengewicht{display:none !important}
/* Nur die Box selbst hoeher setzen, nicht jedes Element mit .carfix: die /* Nur die Box selbst hoeher setzen, nicht jedes Element mit .carfix: die
Klasse haengt auch am <img> und am Platzhalter-<div> (siehe Klasse haengt auch am <img> und am Platzhalter-<div> (siehe
@@ -338,7 +360,7 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
ruhigen Karte. max-width + margin:auto zentriert sie innerhalb des ruhigen Karte. max-width + margin:auto zentriert sie innerhalb des
bestehenden left/right-Abstands, ohne die transform-basierte bestehenden left/right-Abstands, ohne die transform-basierte
Einblend-Animation dieser Elemente zu beruehren. */ Einblend-Animation dieser Elemente zu beruehren. */
.sdpopup,.sheet,.beleg-popup{max-width:560px;margin-left:auto;margin-right:auto} .sdpopup,.sheet,.beleg-popup,.bildmenu{max-width:560px;margin-left:auto;margin-right:auto}
/* .setup-popup breiter als die übrigen Popups: die Sensorkennung neben der /* .setup-popup breiter als die übrigen Popups: die Sensorkennung neben der
Feld-Überschrift (entitaetIdKurz()) kann ein echter Rohsensorname von Feld-Überschrift (entitaetIdKurz()) kann ein echter Rohsensorname von
Herstellerseite sein, teils 40-60 Zeichen lang Herstellerseite sein, teils 40-60 Zeichen lang
@@ -599,8 +599,12 @@ button.leaf:active { background: var(--tile-2); }
.szene.bleed::before { .szene.bleed::before {
content: ""; content: "";
position: absolute; left: 0; right: 0; bottom: 0; position: absolute; left: 0; right: 0; bottom: 0;
height: 42%; /* 26/96 statt 42/78: der Verlauf soll die harte Bildkante ausblenden,
background: linear-gradient(to bottom, transparent, var(--canvas) 78%); 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; pointer-events: none;
z-index: 1; z-index: 1;
} }
@@ -668,7 +672,9 @@ button.leaf:active { background: var(--tile-2); }
.marke { display: inline-flex; align-items: center; gap: 6px; } .marke { display: inline-flex; align-items: center; gap: 6px; }
.marke-logo { display: inline-flex; align-items: center; } .marke-logo { display: inline-flex; align-items: center; }
.marke-logo img { height: 13px; width: auto; } /* Auf Zeilenhoehe (16px) statt 13px: das Markenlogo stand neben Text
derselben Zeile und wirkte daneben zu klein. */
.marke-logo img { height: 16px; width: auto; }
.marke-logo .ph { display: none; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--fg3); } .marke-logo .ph { display: none; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--fg3); }
.marke-logo.logo-fehlt img { display: none; } .marke-logo.logo-fehlt img { display: none; }
.marke-logo.logo-fehlt .ph { display: inline; } .marke-logo.logo-fehlt .ph { display: inline; }
@@ -1011,19 +1017,23 @@ button.leaf:active { background: var(--tile-2); }
gezaehlt, das Blatt war auf dem Geraet rund 16px hoeher als noetig und gezaehlt, das Blatt war auf dem Geraet rund 16px hoeher als noetig und
schob sich entsprechend weiter hoch. Am Rechner faellt das nicht auf, schob sich entsprechend weiter hoch. Am Rechner faellt das nicht auf,
dort ist der Einzug 0. */ dort ist der Einzug 0. */
padding: 0 20px 18px; padding: 0 20px 34px;
transform: translateY(calc(100% - var(--standort-peek))); transform: translateY(calc(100% - var(--standort-peek)));
transition: transform .28s cubic-bezier(.22,.61,.36,1); transition: transform .28s cubic-bezier(.22,.61,.36,1);
} }
/* Aufgezogen rastet das Blatt auf der mittleren Hoehe ein, nicht auf seiner /* Aufgezogen bleibt unter den Knoepfen ein fester Abstand stehen, statt dass
Inhaltshoehe. Apple beschreibt genau das als "detents": Hoehen, auf denen sie buendig mit der Menueleiste abschliessen.
ein Blatt natuerlich zur Ruhe kommt - large ist das voll ausgefahrene
Blatt, medium etwa die Haelfte davon. Vorher war die Rasthoehe schlicht Vorher stand hier min-height:50% - Apples "medium detent". Gemessen ergab
die Hoehe des Inhalts, dadurch schlossen die Knoepfe unten buendig mit der das 89px (Panel) bzw. 76px (App) Luft unter dem letzten Knopf, und das war
Menueleiste ab. Der Ueberschuss liegt unter den Knoepfen und haelt sie von sichtbar zu viel: das Blatt zog ueber die Haelfte des Bildschirms auf,
der Leiste frei. Die eingeklappte Hoehe bleibt unberuehrt: die Verschiebung obwohl sein Inhalt dort laengst zu Ende war. Eine Raste ist nur dann
rechnet mit 100% der jeweiligen Blatthoehe minus dem Guckwert. */ richtig, wenn zwischen ihr und der Inhaltshoehe kein grosses Loch klafft.
.standortmenu.offen { transform: translateY(0); min-height: 50%; }
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. */
.standortmenu.offen { transform: translateY(0); }
.standortmenu-griffzone { padding: 10px 0 14px; touch-action: none; cursor: grab; position: relative; } .standortmenu-griffzone { padding: 10px 0 14px; touch-action: none; cursor: grab; position: relative; }
/* Der Griff ist jetzt ein Knopf (Tastaturzugang zum Menue). Die Knopf- /* Der Griff ist jetzt ein Knopf (Tastaturzugang zum Menue). Die Knopf-
Voreinstellungen des Browsers werden deshalb zurueckgesetzt; die Voreinstellungen des Browsers werden deshalb zurueckgesetzt; die
@@ -1090,38 +1100,62 @@ button.leaf:active { background: var(--tile-2); }
.standort-pille[disabled] { opacity: .4; pointer-events: none; } .standort-pille[disabled] { opacity: .4; pointer-events: none; }
/* ------------------------------------------------------- Bilderverwaltung */ /* ------------------------------------------------------- Bilderverwaltung */
.bildmenu-catcher { position: fixed; inset: 0; z-index: 1000; } /* ---------------------------------------------------------- Blätter
Apples Vorgabe kennt für etwas Modales auf dem Telefon kein frei
schwebendes Fenster: es kommt von der unteren Kante. Das Aktionsblatt
(eine kurze Liste von Wegen) und das Formularblatt (eine Eingabe) sind
die beiden Formen; die Karte in der Bildschirmmitte war keine von
beiden und stand ausserdem ohne abgedunkelten Grund über der voll
sichtbaren Seite.
Der Schleier gehört dazu und ist nicht Geschmack: modal heisst, dass
der Inhalt dahinter nicht bedienbar ist - das soll man sehen. */
.bildmenu-catcher {
position: absolute; inset: 0; z-index: 1000;
background: rgba(0,0,0,.45);
animation: sheet-fade .18s ease-out;
}
.bildmenu { .bildmenu {
position: absolute; z-index: 1001; position: absolute; z-index: 1001;
background: var(--tile-2); left: 10px; right: 10px;
border-radius: 14px; bottom: max(10px, env(safe-area-inset-bottom));
padding: var(--sp-2); top: auto;
transform: none;
background: var(--tile-deckend, var(--canvas));
border-radius: 16px;
overflow: hidden;
padding: 0;
display: flex; flex-direction: column; display: flex; flex-direction: column;
min-width: 160px; animation: sheet-rein .24s cubic-bezier(.22,.61,.36,1);
} }
.bildmenu.rad { top: 90px; left: 22px; } /* Die beiden Lage-Varianten sind gegenstandslos geworden - ein Blatt
/* "Bild der Übersicht": das Vorschaubild füllt die ganze Kachelbreite, seine kommt immer von unten. Die Klassen bleiben im Markup stehen, damit die
Unterkante liegt daher immer nah an der Kachel-Unterkante - das Menü von Aufrufstellen unberührt bleiben; hier tragen sie nichts mehr bei. */
dort aus verankern statt mit einem festen top-Wert, der bei jedem .bildmenu.rad, .bildmenu.ansicht { left: 10px; right: 10px; top: auto; transform: none; }
Bildseitenverhältnis neu nachgemessen werden müsste. */
.bildmenu.ansicht { bottom: var(--sp-5); left: 50%; transform: translateX(-50%); }
.bildmenu button { .bildmenu button {
display: block; width: 100%; text-align: left; display: block; width: 100%; text-align: left;
background: none; border: none; background: none; border: none;
color: var(--fg); font-family: inherit; font-size: 15px; color: var(--fg); font-family: inherit; font-size: 16px;
padding: 13px 10px; min-height: 46px; border-radius: 10px; padding: 16px 18px; min-height: 52px; border-radius: 0;
border-bottom: 1px solid var(--line);
cursor: pointer; cursor: pointer;
} }
.bildmenu button:last-child { border-bottom: none; }
.bildmenu button:active { background: var(--shade); } .bildmenu button:active { background: var(--shade); }
.bildmenu button.loeschen { color: var(--bad); } .bildmenu button.loeschen { color: var(--bad); }
/* Formularblatt (siehe Kasten oben): unten angeschlagen, nur oben rund,
blickdicht. Stand vorher als Karte 60px unter dem oberen Rand - ausser
Daumenreichweite und ohne sichtbaren Weg hinaus. */
.sdpopup { .sdpopup {
position: absolute; z-index: 1001; position: absolute; z-index: 1001;
left: var(--sp-5); right: var(--sp-5); top: 60px; left: 0; right: 0; bottom: 0; top: auto;
background: var(--tile-2); background: var(--tile-deckend, var(--canvas));
border-radius: 14px; border-radius: 16px 16px 0 0;
padding: var(--sp-4); padding: var(--sp-4) 20px max(20px, env(safe-area-inset-bottom));
animation: blatt-hoch .28s cubic-bezier(.22,.61,.36,1);
} }
@keyframes blatt-hoch { from { transform: translateY(100%) } to { transform: none } }
/* Beleg-Fenster: PDF hineinziehen (Rechner) oder einfügen (Telefon). */ /* Beleg-Fenster: PDF hineinziehen (Rechner) oder einfügen (Telefon). */
.beleg-catcher { .beleg-catcher {
@@ -1129,15 +1163,20 @@ button.leaf:active { background: var(--tile-2); }
background: rgba(0,0,0,.45); background: rgba(0,0,0,.45);
animation: sheet-fade .18s ease-out; animation: sheet-fade .18s ease-out;
} }
/* Formularblatt (siehe Kasten oben). Traegt drei Fenster: Beleg
hochladen, Daten importieren, Datensatz sichern/laden. */
.beleg-popup { .beleg-popup {
position: absolute; z-index: 1001; position: absolute; z-index: 1001;
left: var(--sp-5); right: var(--sp-5); left: 0; right: 0; bottom: 0;
top: 50%; transform: translateY(-50%); top: auto; transform: none;
/* Wie .setup-popup: --tile-2 ist im Nacht-Theme transluzent, ein frei max-height: 88%;
schwebendes Fenster braucht einen blickdichten Grund. */ overflow-y: auto;
/* --tile-2 ist im Nacht-Theme transluzent; ein Blatt ueber der Seite
braucht einen blickdichten Grund. */
background: var(--tile-deckend, var(--canvas)); background: var(--tile-deckend, var(--canvas));
border-radius: var(--r-tile); border-radius: 16px 16px 0 0;
padding: var(--sp-4); padding: var(--sp-4) 20px max(20px, env(safe-area-inset-bottom));
animation: blatt-hoch .28s cubic-bezier(.22,.61,.36,1);
} }
.beleg-drop { .beleg-drop {
margin-top: var(--sp-3); margin-top: var(--sp-3);
@@ -1,7 +1,7 @@
{ {
"domain": "audi_dashboard", "domain": "audi_dashboard",
"name": "Audi Dashboard", "name": "Audi Dashboard",
"version": "2026.8.30.20", "version": "2026.8.31.1",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": ["@paul"], "codeowners": ["@paul"],
@@ -33,18 +33,34 @@ export function ImagePlaceholder({
size = "default", size = "default",
className, className,
}: ImagePlaceholderProps) { }: ImagePlaceholderProps) {
const [failed, setFailed] = React.useState(false); /*
* Gemerkt wird die Adresse, die fehlgeschlagen ist - nicht ein blosses
* "ist fehlgeschlagen".
*
* Der Unterschied ist der Fehler, den das behebt: In der Fotogalerie unter
* "Mein Audi" bleibt dieselbe Komponente stehen und bekommt nur eine neue
* src. Mit einem blossen Wahrheitswert blieb ein einmal gesetztes true fuer
* immer stehen - sobald ein noch leerer Platz durchlaufen war, zeigten auch
* alle folgenden Plaetze mit echtem Foto nur noch den Platzhalter. Erst ein
* Seitenwechsel setzte den Zustand zurueck, weil die Komponente dabei neu
* entsteht (gemeldet vom Eigentuemer, 2026-08-31).
*
* Bewusst ohne useEffect: ein Vergleich beim Rendern kommt ohne den
* zusaetzlichen Durchlauf aus, den ein Zuruecksetzen im Effekt braucht -
* und in dem waere fuer einen Wimpernschlag das falsche Bild zu sehen.
*/
const [fehlgeschlagen, setzeFehlgeschlagen] = React.useState<string | null>(null);
const classes = ["ads-bildbox"]; const classes = ["ads-bildbox"];
if (size === "mini") classes.push("ads-bildbox--mini"); if (size === "mini") classes.push("ads-bildbox--mini");
if (size === "round") classes.push("ads-bildbox--round"); if (size === "round") classes.push("ads-bildbox--round");
if (className) classes.push(className); if (className) classes.push(className);
const showPlaceholder = !src || failed; const showPlaceholder = !src || fehlgeschlagen === src;
return ( return (
<div className={classes.join(" ")}> <div className={classes.join(" ")}>
{!showPlaceholder && ( {!showPlaceholder && (
<img src={src} alt={alt} onError={() => setFailed(true)} /> <img src={src} alt={alt} onError={() => setzeFehlgeschlagen(src)} />
)} )}
{showPlaceholder && ( {showPlaceholder && (
<div className="ads-platzhalter"> <div className="ads-platzhalter">
@@ -18,3 +18,15 @@
color: var(--red); color: var(--red);
border-color: var(--red); border-color: var(--red);
} }
/* Als Knopf: die Optik bleibt exakt die der Anzeige-Pille, nur die
Browser-Vorgaben fuer <button> werden zurueckgenommen. Deckungsgleich mit
`.pill` im Panel, wo dieselbe Pille mit style="cursor:pointer;
font-family:inherit" als Knopf gerendert wird. */
.ads-pill--knopf {
font: inherit;
font-size: 12.5px;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
+24 -1
View File
@@ -5,11 +5,34 @@ export interface PillProps {
/** "work" recolors border+text to the accent red - use for a highlighted tag. */ /** "work" recolors border+text to the accent red - use for a highlighted tag. */
variant?: "default" | "work"; variant?: "default" | "work";
className?: string; className?: string;
/**
* Macht aus der Pille einen Knopf.
*
* Nicht kosmetisch: im Panel ist die Art-Pille einer Fahrt seit jeher ein
* `<button data-art>`, das zwischen Privat und Arbeitsweg umschaltet. Ohne
* diesen Zweig war sie in der App ein totes `<span>` - dieselbe Optik, aber
* ohne die Bedienung, was von aussen wie ein Fehler aussieht und es de facto
* auch war.
*
* Fehlt onClick, bleibt es beim `<span>`: eine Pille, die nur etwas anzeigt,
* soll auch nicht per Tabulator erreichbar sein.
*/
onClick?: () => void;
/** Nur mit onClick sinnvoll - beschreibt die Wirkung, nicht den Zustand. */
ariaLabel?: string;
} }
export function Pill({ children, variant = "default", className }: PillProps) { export function Pill({ children, variant = "default", className, onClick, ariaLabel }: PillProps) {
const classes = ["ads-pill"]; const classes = ["ads-pill"];
if (variant === "work") classes.push("ads-pill--work"); if (variant === "work") classes.push("ads-pill--work");
if (onClick) classes.push("ads-pill--knopf");
if (className) classes.push(className); if (className) classes.push(className);
if (onClick) {
return (
<button type="button" className={classes.join(" ")} onClick={onClick} aria-label={ariaLabel}>
{children}
</button>
);
}
return <span className={classes.join(" ")}>{children}</span>; return <span className={classes.join(" ")}>{children}</span>;
} }
+21 -1
View File
@@ -48,7 +48,27 @@ export function Popup({
</> </>
); );
const target = portalTarget ?? (typeof document !== "undefined" ? document.body : null); /*
* Ziel des Portals ist die App-Wurzel, NICHT document.body.
*
* Die Farbwerte haengen an [data-theme] auf dem .ads-root-Element (siehe
* tokens/tokens.css: :root traegt den Nachtmodus, [data-theme="tag"]
* ueberschreibt ihn). Ein nach document.body portaliertes Fenster steht
* ausserhalb dieses Elements und erbt damit immer den Nachtmodus - im
* Tagmodus gemessen am 2026-08-31: innerhalb --tile:#FFFFFF, auf body
* --tile:rgba(255,255,255,.055). Das Fenster war dadurch eine fast
* durchsichtige Flaeche auf weissem Grund, ohne erkennbare Kante.
*
* Das betraf jedes Popup der App gleichermassen und war kein Fehler der
* einzelnen Aufrufe - deshalb sitzt die Korrektur hier und nicht dort.
*
* position:fixed bleibt davon unberuehrt: .ads-root setzt weder transform
* noch filter, der umschliessende Block bleibt also das Sichtfenster, und
* ein overflow:hidden weiter oben schneidet ein solches Element nicht ab.
*/
const target =
portalTarget ??
(typeof document !== "undefined" ? (document.querySelector(".ads-root") ?? document.body) : null);
return target ? createPortal(content, target) : content; return target ? createPortal(content, target) : content;
} }
@@ -0,0 +1,98 @@
import * as React from "react";
import { createPortal } from "react-dom";
import { sheetPortalTarget, useEscape } from "./Sheet";
/**
* Action sheet - the iOS pattern for a short set of choices the user asked for.
*
* Two groups, exactly as Apple describes them: the choices in one rounded
* card, "Cancel" alone in a second one below it with a gap between. The gap is
* not decoration - it is what makes Cancel unmistakably the way out rather
* than one more option in the list.
*
* Deliberately identical in geometry to `.sheet` in the panel's
* audi-dashboard.css, which already implemented this pattern: 10px side
* margins, 16px corners, 52px minimum row height, one hairline between rows,
* opaque surface. Two codebases, one look.
*
* Use `Sheet` instead when the content is a form rather than a list of
* choices.
*/
export interface ActionSheetAktion {
label: React.ReactNode;
onClick: () => void;
/** Red, for anything that removes or overwrites something. */
destructive?: boolean;
disabled?: boolean;
}
export interface ActionSheetProps {
open: boolean;
onClose: () => void;
/** Optional heading above the choices. */
title?: React.ReactNode;
/** One explanatory line under the title. */
description?: React.ReactNode;
actions: ActionSheetAktion[];
/** Label of the separated bottom button. Pass null to leave it out. */
cancelLabel?: string | null;
/** Rendered between the heading and the choices - an error line, a hint. */
children?: React.ReactNode;
className?: string;
portalTarget?: Element;
}
export function ActionSheet({
open,
onClose,
title,
description,
actions,
cancelLabel = "Abbrechen",
children,
className,
portalTarget,
}: ActionSheetProps) {
useEscape(open, onClose);
const target = sheetPortalTarget(portalTarget);
if (!open || !target) return null;
const classes = ["ads-aktionsblatt"];
if (className) classes.push(className);
return createPortal(
<>
<div className="ads-sheet-scrim" onClick={onClose} />
<div className={classes.join(" ")} role="dialog" aria-modal="true">
<div className="ads-aktionsblatt__gruppe">
{(title || description) && (
<div className="ads-aktionsblatt__kopf">
{title && <strong>{title}</strong>}
{description && <span>{description}</span>}
</div>
)}
{children && <div className="ads-aktionsblatt__zusatz">{children}</div>}
{actions.map((aktion, i) => (
<button
key={i}
type="button"
className={aktion.destructive ? "ads-aktionsblatt__loeschen" : undefined}
disabled={aktion.disabled}
onClick={aktion.onClick}
>
{aktion.label}
</button>
))}
</div>
{cancelLabel !== null && (
<button type="button" className="ads-aktionsblatt__abbrechen" onClick={onClose}>
{cancelLabel}
</button>
)}
</div>
</>,
target,
);
}
@@ -0,0 +1,214 @@
/* -------------------------------------------------------------- Blätter
Zwei Bausteine, ein Muster: etwas Modales kommt von der unteren Kante,
nicht aus der Bildschirmmitte. Apples Vorgabe reserviert die schwebende
Karte (Popover) für große Bildschirme; auf dem Telefon soll daraus ein
Blatt werden.
Der abdunkelnde Schleier gehört dazu und ist nicht Geschmack: modal heißt,
dass der Inhalt dahinter nicht bedienbar ist - das soll man sehen. Die alte
.ads-popup-Fassung hatte gar keinen (background:none), die Karte schwebte
ohne Trennung über der noch voll sichtbaren Seite. */
.ads-sheet-scrim {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, .45);
border: none;
animation: ads-blatt-blende .18s ease-out;
}
@keyframes ads-blatt-blende {
from { opacity: 0 }
to { opacity: 1 }
}
@keyframes ads-blatt-hoch {
from { transform: translateY(100%) }
to { transform: none }
}
@keyframes ads-blatt-rein {
from { transform: translateY(18px); opacity: 0 }
to { transform: none; opacity: 1 }
}
/* ------------------------------------------------------------- Formular */
.ads-sheet {
position: fixed;
z-index: 1001;
left: 0;
right: 0;
bottom: 0;
/* Blickdicht, nie durchscheinend: --tile-2 ist im Nachtthema ein rgba-Weiß
für Kachel-auf-Kachel und ließ die Seite durch den Dialog schimmern.
Dieselbe Korrektur trägt das Panel längst in .sheet-gruppe und
.setup-popup. */
background: var(--tile-deckend, var(--canvas));
/* Nur oben rund - das Blatt sitzt auf der Kante auf. */
border-radius: 16px 16px 0 0;
padding: 0 20px max(20px, env(safe-area-inset-bottom));
max-height: 88%;
display: flex;
flex-direction: column;
animation: ads-blatt-hoch .28s cubic-bezier(.22, .61, .36, 1);
}
/* Breite Bildschirme: ein Blatt über die volle Breite eines Monitors wäre
ein Band, kein Dialog. Dieselbe Obergrenze wie die Popups des Panels. */
@media (min-width: 700px) {
.ads-sheet {
max-width: 560px;
margin-left: auto;
margin-right: auto;
border-radius: 16px;
bottom: 20px;
left: 20px;
right: 20px;
}
}
/* Greifer: das sichtbare Versprechen, dass sich das Blatt wegschieben lässt.
Maße wie der Griff des Standort-Blattes (36 x 4). */
.ads-sheet__griff {
display: block;
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--line-strong);
margin: 8px auto 4px;
flex: none;
}
.ads-sheet__kopf {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 0 12px;
flex: none;
}
.ads-sheet__kopf strong {
font-size: 17px;
font-weight: 400;
color: var(--fg);
}
.ads-sheet__kopf span {
font-size: 13px;
line-height: 1.45;
color: var(--fg2);
}
/* Der Inhalt scrollt, nicht das Blatt: der Greifer und die Überschrift sollen
stehen bleiben, wenn ein Formular länger wird als der Bildschirm. */
.ads-sheet__inhalt {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* ------------------------------------------------------------- Auswahl */
.ads-aktionsblatt {
position: fixed;
z-index: 1001;
left: 10px;
right: 10px;
bottom: max(10px, env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: 8px;
animation: ads-blatt-rein .24s cubic-bezier(.22, .61, .36, 1);
}
@media (min-width: 700px) {
.ads-aktionsblatt {
max-width: 560px;
margin-left: auto;
margin-right: auto;
}
}
.ads-aktionsblatt__gruppe {
background: var(--tile-deckend, var(--canvas));
border-radius: 16px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.ads-aktionsblatt__kopf {
padding: 15px 18px 14px;
border-bottom: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 4px;
}
.ads-aktionsblatt__kopf strong {
font-size: 15px;
font-weight: 400;
color: var(--fg);
}
.ads-aktionsblatt__kopf span {
font-size: 13px;
color: var(--fg2);
line-height: 1.45;
}
.ads-aktionsblatt__zusatz {
padding: 12px 18px;
border-bottom: 1px solid var(--line);
}
.ads-aktionsblatt__gruppe button {
background: none;
border: none;
font-family: inherit;
font-size: 16px;
color: var(--fg);
padding: 16px 18px;
min-height: 52px;
text-align: left;
cursor: pointer;
border-bottom: 1px solid var(--line);
}
.ads-aktionsblatt__gruppe button:last-child {
border-bottom: none;
}
.ads-aktionsblatt__gruppe button:active {
background: var(--shade);
}
.ads-aktionsblatt__gruppe button:disabled {
color: var(--fg3);
cursor: default;
}
.ads-aktionsblatt__loeschen {
color: var(--bad);
}
/* Eigene Karte mit Abstand darüber - das ist es, was "Abbrechen" vom Rest der
Liste trennt und es unmissverständlich zum Ausweg macht, statt zu einer
weiteren Wahlmöglichkeit. */
.ads-aktionsblatt__abbrechen {
background: var(--tile-deckend, var(--canvas));
border: none;
border-radius: 16px;
font-family: inherit;
font-size: 16px;
color: var(--fg);
padding: 16px;
min-height: 52px;
cursor: pointer;
}
.ads-aktionsblatt__abbrechen:active {
background: var(--shade);
}
@@ -0,0 +1,103 @@
import * as React from "react";
import { createPortal } from "react-dom";
/**
* Modal sheet - the iOS pattern for a task that needs input.
*
* WHY THIS REPLACES `Popup`
* -------------------------
* `Popup` renders a card floating in the middle of the screen. On iPhone that
* is a popover, and Apple's Human Interface Guidelines reserve popovers for
* larger screens: on a phone they are expected to adapt into a sheet that
* slides up from the bottom edge. A centred card is also harder to reach with
* a thumb, and it gave no hint of how to get rid of it.
*
* The card was translucent on top of that (`--tile-2` is an rgba white in the
* night theme, meant for tile-on-tile layering) so page content showed through
* the dialog. The panel had already hit this and fixed it for its own sheet
* and setup dialog by switching to the opaque surface token; this component
* starts there.
*
* WHAT MAKES IT A SHEET
* ---------------------
* - anchored to the bottom edge, rounded top corners only
* - a grabber, so it reads as draggable and dismissible
* - a dimmed scrim: modal means the content behind is out of reach, and it
* should look that way
* - opaque surface, never see-through
* - the bottom safe area stays clear of the home indicator
* - Escape closes it, and so does a tap on the scrim
*
* `ActionSheet` is the sibling for a set of choices; use that one when the
* sheet is a list of buttons rather than a form.
*/
/**
* Portal target for both sheet components: the themed app root, never
* `document.body`. The colour tokens hang off `[data-theme]` on `.ads-root`;
* anything portalled outside it inherits the night palette even while the app
* shows the day one (measured 2026-08-31 - see Popup.tsx).
*/
export function sheetPortalTarget(portalTarget?: Element): Element | null {
if (portalTarget) return portalTarget;
if (typeof document === "undefined") return null;
return document.querySelector(".ads-root") ?? document.body;
}
/** Escape closes the topmost modal - expected wherever there is a keyboard. */
export function useEscape(open: boolean, onClose: () => void): void {
React.useEffect(() => {
if (!open) return;
const beiTaste = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", beiTaste);
return () => document.removeEventListener("keydown", beiTaste);
}, [open, onClose]);
}
export interface SheetProps {
open: boolean;
onClose: () => void;
/** Shown at the top of the sheet, above the content. */
title?: React.ReactNode;
/** One explanatory line under the title. */
description?: React.ReactNode;
children: React.ReactNode;
className?: string;
portalTarget?: Element;
}
export function Sheet({
open,
onClose,
title,
description,
children,
className,
portalTarget,
}: SheetProps) {
useEscape(open, onClose);
const target = sheetPortalTarget(portalTarget);
if (!open || !target) return null;
const classes = ["ads-sheet"];
if (className) classes.push(className);
return createPortal(
<>
<div className="ads-sheet-scrim" onClick={onClose} />
<div className={classes.join(" ")} role="dialog" aria-modal="true">
<span className="ads-sheet__griff" aria-hidden="true" />
{(title || description) && (
<div className="ads-sheet__kopf">
{title && <strong>{title}</strong>}
{description && <span>{description}</span>}
</div>
)}
<div className="ads-sheet__inhalt">{children}</div>
</div>
</>,
target,
);
}
@@ -0,0 +1,4 @@
export { Sheet, sheetPortalTarget, useEscape } from "./Sheet";
export type { SheetProps } from "./Sheet";
export { ActionSheet } from "./ActionSheet";
export type { ActionSheetProps, ActionSheetAktion } from "./ActionSheet";
+3
View File
@@ -46,6 +46,9 @@ export type { SwipeRowProps } from "./components/SwipeRow";
export { TabBar } from "./components/TabBar"; export { TabBar } from "./components/TabBar";
export type { TabBarProps, TabBarItem } from "./components/TabBar"; export type { TabBarProps, TabBarItem } from "./components/TabBar";
export { Sheet, ActionSheet } from "./components/Sheet";
export type { SheetProps, ActionSheetProps, ActionSheetAktion } from "./components/Sheet";
export { Popup, PopupMenuItem } from "./components/Popup"; export { Popup, PopupMenuItem } from "./components/Popup";
export type { PopupProps, PopupMenuItemProps, PopupAnchor } from "./components/Popup"; export type { PopupProps, PopupMenuItemProps, PopupAnchor } from "./components/Popup";
+1
View File
@@ -18,5 +18,6 @@
@import "./components/Accordion/Accordion.css"; @import "./components/Accordion/Accordion.css";
@import "./components/SwipeRow/SwipeRow.css"; @import "./components/SwipeRow/SwipeRow.css";
@import "./components/TabBar/TabBar.css"; @import "./components/TabBar/TabBar.css";
@import "./components/Sheet/Sheet.css";
@import "./components/Popup/Popup.css"; @import "./components/Popup/Popup.css";
@import "./components/ImagePlaceholder/ImagePlaceholder.css"; @import "./components/ImagePlaceholder/ImagePlaceholder.css";
+42 -1
View File
@@ -19,8 +19,12 @@
"@aparajita/capacitor-secure-storage": "^8.0.0", "@aparajita/capacitor-secure-storage": "^8.0.0",
"@audi-dash/ui": "*", "@audi-dash/ui": "*",
"@capacitor/android": "^8.5.0", "@capacitor/android": "^8.5.0",
"@capacitor/app": "^8.1.1",
"@capacitor/core": "^8.5.0", "@capacitor/core": "^8.5.0",
"@capacitor/filesystem": "^8.1.3",
"@capacitor/ios": "^8.5.0", "@capacitor/ios": "^8.5.0",
"@capacitor/preferences": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@capgo/capacitor-updater": "^8.51.14", "@capgo/capacitor-updater": "^8.51.14",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react": "^18.3.1", "react": "^18.3.1",
@@ -37,7 +41,8 @@
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.7", "vite": "^6.0.7",
"vitest": "^2.1.8" "vitest": "^2.1.8",
"xcode": "^3.0.1"
} }
}, },
"design-system": { "design-system": {
@@ -516,6 +521,18 @@
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
}, },
"node_modules/@capacitor/filesystem": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-8.1.3.tgz",
"integrity": "sha512-GNBlRkaXEEb+6WP/YsDXr2zygyOHoieuye6viyd2ZkEdBwfseG41jxfarVxLWH0r0owhyNUbwJ/Qi8fubqr6wA==",
"license": "MIT",
"dependencies": {
"@capacitor/synapse": "^1.0.4"
},
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/ios": { "node_modules/@capacitor/ios": {
"version": "8.5.0", "version": "8.5.0",
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.5.0.tgz", "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.5.0.tgz",
@@ -534,6 +551,30 @@
"@capacitor/core": ">=8.0.0" "@capacitor/core": ">=8.0.0"
} }
}, },
"node_modules/@capacitor/preferences": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-8.0.1.tgz",
"integrity": "sha512-T6no3ebi79XJCk91U3Jp/liJUwgBdvHR+s6vhvPkPxSuch7z3zx5Rv1bdWM6sWruNx+pViuEGqZvbfCdyBvcHQ==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/share": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/share/-/share-8.0.1.tgz",
"integrity": "sha512-3cSBKBCJVon54rKDROP2rqGyeGks4pBh9TbaEk9S375Kbek/ZHe72N50zIa0Vn9Eac/SuhwgehO/mmA4CsUOiw==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/synapse": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
"license": "ISC"
},
"node_modules/@capgo/capacitor-updater": { "node_modules/@capgo/capacitor-updater": {
"version": "8.51.14", "version": "8.51.14",
"resolved": "https://registry.npmjs.org/@capgo/capacitor-updater/-/capacitor-updater-8.51.14.tgz", "resolved": "https://registry.npmjs.org/@capgo/capacitor-updater/-/capacitor-updater-8.51.14.tgz",