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:
@@ -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, 76–89 zu viel); Nebel über dem
|
||||||
|
Fahrzeugfoto von 42 %/78 % auf 26 %/96 %; FIN statt „Fahrgestellnummer"; Solldruckfelder auf 58 px
|
||||||
|
(die Klasse stand im Markup, hatte aber keine Regel); Markenlogos einheitlich auf Zeilenhöhe 16 px;
|
||||||
|
`watchPosition` statt `getCurrentPosition` für die Live-Ortung, mit Abmeldung beim Verlassen der
|
||||||
|
Seite; `NSLocationWhenInUseUsageDescription` in `ios-signieren.sh` (in der ausgelieferten `.ipa`
|
||||||
|
fehlte jeder Location-Schlüssel — deshalb fragte iOS nie).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("")
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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"}
|
||||||
Binary file not shown.
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Generated
+42
-1
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user