diff --git a/AGENTS.md b/AGENTS.md index 5fbfd5a..2f6d893 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6068,3 +6068,123 @@ rendering bug in a view. `custom_components/audi_dashboard/` directly. Do not reintroduce a copy. - Security principle: HA is never publicly exposed; only narrowly scoped surfaces (MQTT broker, proxy allowlist) may be exposed, each by explicit decision. + +--- + +## AR. Teilen-Blatt, Kalender und der Rest des Owner-Stapels vom 2026-08-31 + +Alles unter `companion-app/`, sofern nicht anders vermerkt; Panel-Anteile nach der Paritätsregel. + +### Share-Erweiterung: alles versioniert, damit `cap add ios` sie nicht wieder frisst + +Ein PDF aus Mail über **Teilen → DataMetric360** landet als Tankbeleg in der App. Der Weg im +Ganzen steht in `companion-app/docs/SHARE_EXTENSION.md`; das Wesentliche hier: + +- Eine Erweiterung ist ein eigener Prozess. Der einzige gemeinsame Boden mit der App ist eine + **App-Gruppe** (`group.app.datametric360`). Das PDF reist Base64-kodiert durch die geteilten + `UserDefaults`, weil `@capacitor/preferences` genau die liest, sobald man ihm die Gruppe nennt — + eine Datei im Gruppen-Container bräuchte eigenen nativen Code auf der App-Seite, also ein + zusätzliches Plugin für einen Weg, den es sonst nicht gäbe. Preis: 4-MB-Grenze, sauber gemeldet. +- **`companion-app/ios/` ist gitignored.** Alles Native liegt deshalb unter + `companion-app/native/` und wird von `scripts/ios-teilen-einrichten.mjs` bei jedem Bau wieder + eingespielt — dieselbe Regel, die schon die Team-Kennung und den Standort-Schlüssel in + `ios-signieren.sh` getrieben hat. Das Skript ist idempotent und in `ios-signieren.sh` + eingehängt. +- **Der pbxproj-Teil ist auf keinem Mac erprobt** (geschrieben ohne Zugriff auf macOS). Er bricht + vor dem Schreiben ab, wenn etwas nicht passt, und `docs/SHARE_EXTENSION.md` beschreibt dieselben + Handgriffe für Xcode. Nicht als „fertig verifiziert" behandeln, bevor es auf dem Mac gelaufen ist. +- Einmalig im Apple-Portal nötig: App-Gruppe anlegen, `app.datametric360.teilen` anlegen, beide + App-IDs der Gruppe zuordnen. Ohne das schlägt das Signieren fehl. +- Die App-Seite ist geprüft (6 Tests in `src/daten/geteilterBeleg.test.ts`): Eintrag → `File`, + genau einmal abgeholt, liegengebliebene und unlesbare Einträge verworfen. + +### „In den Kalender übernehmen" ging in der App nie + +`` mit Blob-Adresse — **WKWebView ignoriert `download`**, der Klick lief ins Leere. +Auf dem Gerät jetzt `@capacitor/filesystem` (Cache) plus `@capacitor/share`; im Browser und im +Panel bleibt der Download. Betrifft Reifen-Wechseltermin und Service-Werkstatttermin. + +### Ein Wert, viele Kopien: `useTheme`/`useBoden` waren pro Aufrufer eigener Zustand + +`useTheme()` legte bei jedem Aufruf ein eigenes `useState` an — fünf unabhängige Kopien +(`App.tsx`, `Einstellungen.tsx`, `Typenschild.tsx` ×2). Der Schalter änderte nur seine eigene; +`data-theme` in `App.tsx` blieb stehen, die Umschaltung wirkte erst nach einem Neustart. Der +Boden-Schalter hatte denselben Fehler. Jetzt `useSyncExternalStore` über einen Speicher je +Einstellung in `theme.ts` — Reacts eigener Weg für Zustand außerhalb des Komponentenbaums, ohne +Kontext-Anbieter und ohne geänderte Aufrufstellen. Zwei Regressionstests, gegen den alten Stand +als fehlschlagend nachgewiesen. + +**Regel:** eine Einstellung, die mehr als eine Komponente liest, gehört nicht in `useState` je +Aufrufer. + +### `uebersichtsbild`: Panel und App speicherten Verschiedenes ins selbe Feld + +Panel schreibt den **Namen** („Seitenansicht"), die App verglich gegen den **Dateinamen**. Der +Vergleich konnte nie zutreffen — die App zeigte immer den ersten Platz. Kanonisch ist der Name +(die ältere, bereits gespeicherte Form; dieselbe Entscheidung wie beim Wartungsplan, Abschnitt AE). +Korrigiert in `Einstellungen.tsx`, `Uebersicht.tsx` und `profilAdapter.ts`. + +### Popups nach Apple HIG + +Neu im Design-System: `Sheet` (Formularblatt) und `ActionSheet` (Auswahlblatt, „Abbrechen" +abgesetzt in eigener Karte). Beide blickdicht, mit abgedunkeltem Schleier, sichere Zone unten frei, +Escape schließt. Umgestellt: SmartDeal, Tankbeleg, Bildmenü, Datensatz, Historien-Import — und im +Panel `.bildmenu`, `.sdpopup`, `.beleg-popup` samt Schleier. Das Setup-Fenster bleibt: es füllt +ohnehin die volle Höhe und ist damit bereits die HIG-Form „Vollbild". + +`menueLage` („ansicht"/„rad") ist entfallen — ein Aktionsblatt kommt immer von unten. + +### `Popup` portalierte nach `document.body` — außerhalb des Themes + +Die Farbwerte hängen an `[data-theme]` auf `.ads-root`. Alles, was nach `document.body` portaliert +wird, erbt deshalb **immer** den Nachtmodus: im Tagmodus war jedes Fenster eine zu 5 % deckende +weiße Fläche auf weißem Grund. Betraf alle vier bestehenden Dialoge. `Popup`/`Sheet`/`ActionSheet` +zielen jetzt auf `.ads-root`. + +**Regel:** kein Portal nach `document.body`. `position: fixed` bleibt davon unberührt — `.ads-root` +setzt kein `transform`. + +### `ImagePlaceholder` merkte sich „fehlgeschlagen" ohne die Adresse + +In der Galerie unter „Mein Audi" bleibt dieselbe Komponente stehen und bekommt nur eine neue +`src`. Ein einmal gesetztes `true` galt damit für alle folgenden Bilder — nach einem leeren Platz +zeigten auch die mit Foto nur noch den Platzhalter. Jetzt wird die Adresse gemerkt. Das Panel hatte +die Falle nie: `bildWeiter()` nimmt `bild-fehlt` ausdrücklich wieder weg. Vier Regressionstests, +gegen den alten Stand als fehlschlagend nachgewiesen. + +### Bildadressen ohne Cache-Brecher + +HA liefert `/local/` mit `Cache-Control: public, max-age=2678400` — **31 Tage** (nachgemessen). +Das Panel hängt seit jeher `?v=` an, die App nicht: ein im Panel eingefügtes Radfoto erschien in +der App nicht, ein ersetztes blieb das alte, ein 404 blieb ein 404. `bildUrl()` trägt jetzt einen +Bildstand, erneuert bei Start und nach jedem Hochladen/Löschen. + +### Datumsfelder zeigten ihren Wert erst nach dem ersten Antippen + +Alle sieben hängen an Werten, die asynchron eintreffen; WKWebView zieht den inneren Aufbau eines +`` bei einer späteren Wertänderung nicht zuverlässig nach. Neue gemeinsame +Komponente `Datumsfeld`, deren Schlüssel genau einmal wechselt (leer → gefüllt). Bewusst nicht der +Wert selbst als Schlüssel: dann entstünde das Feld bei jeder Auswahl neu und die Datumsauswahl +schlösse mitten in der Bedienung. **Auf dem Gerät zu bestätigen** — WebKit-spezifisch, im Chromium +der Vorschau nicht nachstellbar. + +### Setup: „(z. B. …)" bleibt stehen + +Der Hinweis kam aus der gerade zugeordneten Entität und änderte sich mit jeder Auswahl mit. +Vorgeschichte: bis 2026-08-28 stand dort ein ausgedachtes Beispiel, das für zwei Felder falsch war. +Beide Beanstandungen zusammen: **fest, aber nicht ausgedacht** — die 22 Beispiele stehen jetzt als +`beispiel` im `FELDER`-Katalog und sind die abgelesenen Namen der laufenden Integration. +`entitaetIdKurz()` und zwei Helfer (rund 60 Zeilen) haben keinen Aufrufer mehr und sind entfallen. + +### Weiteres aus demselben Stapel + +Fahrtrichtung als Dreiecke statt Punkte auf der Streckenlinie (Abstand nach Strecke, nicht nach +Stützpunkten — die Punktdichte hängt an der Geschwindigkeit); Art-Pille auf der Einzelfahrt ist +wieder ein Knopf (im Panel seit jeher, in der App ein totes ``); Kopf-Gegengewicht, damit der +Titel auf Unterseiten wirklich mittig steht (gemessen: 17 px zu weit rechts, beide Codebasen); +Standort-Blatt ohne `min-height`, 34 px Fußabstand (18 war zu wenig, 76–89 zu viel); Nebel über dem +Fahrzeugfoto von 42 %/78 % auf 26 %/96 %; FIN statt „Fahrgestellnummer"; Solldruckfelder auf 58 px +(die Klasse stand im Markup, hatte aber keine Regel); Markenlogos einheitlich auf Zeilenhöhe 16 px; +`watchPosition` statt `getCurrentPosition` für die Live-Ortung, mit Abmeldung beim Verlassen der +Seite; `NSLocationWhenInUseUsageDescription` in `ios-signieren.sh` (in der ausgelieferten `.ipa` +fehlte jeder Location-Schlüssel — deshalb fragte iOS nie). diff --git a/companion-app/docs/SHARE_EXTENSION.md b/companion-app/docs/SHARE_EXTENSION.md new file mode 100644 index 0000000..095fe1b --- /dev/null +++ b/companion-app/docs/SHARE_EXTENSION.md @@ -0,0 +1,120 @@ +# Tankbeleg aus dem Teilen-Blatt + +Ein PDF aus Mail (oder einer anderen App) über **Teilen → DataMetric360** direkt +als Tankbeleg übernehmen. + +## Wie es zusammenhängt + +``` +Mail ──Teilen──► DataMetric360Share (eigener Prozess) + │ + │ PDF → Base64 → UserDefaults der App-Gruppe + │ group.app.datametric360 + ▼ + datametric360://beleg (öffnet die App) + │ + ▼ + App.tsx ──holt ab──► Tanken-Seite ──► belegLesen() +``` + +Eine Erweiterung ist ein eigener Prozess mit eigenem Dateibereich; der einzige +gemeinsame Boden mit der App ist die **App-Gruppe**. Der Beleg reist deshalb +Base64-kodiert durch die geteilten `UserDefaults` — die liest +`@capacitor/preferences` ohnehin schon, sobald man ihm die Gruppe nennt. Eine +Datei im Gruppen-Container wäre der sauberere Ort, bräuchte auf der App-Seite +aber eigenen nativen Code, also ein zusätzliches Plugin für einen Weg, den es +sonst nicht gäbe. + +Der Preis ist eine Größengrenze von 4 MB (`grenzeBytes` im +`ShareViewController`). Ein Tankbeleg liegt bei einigen Dutzend Kilobyte; ein +gescanntes Mehrseiten-PDF kann darüber liegen — dann sagt die Erweiterung das, +und der Weg über **Beleg hochladen → Datei auswählen** in der App bleibt. + +## Wo was liegt + +| Datei | Rolle | +|---|---| +| `native/teilen/ShareViewController.swift` | nimmt das PDF entgegen, legt es ab, öffnet die App | +| `native/teilen/Info.plist` | wann DataMetric360 im Teilen-Blatt erscheint (genau ein PDF) | +| `native/teilen/DataMetric360Share.entitlements` | App-Gruppe für die Erweiterung | +| `native/App.entitlements` | dieselbe App-Gruppe für die App | +| `scripts/ios-teilen-einrichten.mjs` | hängt das alles ins erzeugte Xcode-Projekt | +| `src/daten/geteilterBeleg.ts` | holt den Beleg ab, räumt ihn weg | +| `src/App.tsx` | prüft beim Start und bei jeder Rückkehr, springt auf Tanken | +| `src/screens/Tanken.tsx` | nimmt ihn auf und lädt ihn hoch | + +**`companion-app/ios/` ist gitignored** und wird von `npx cap add ios` neu +gebaut. Alles Native muss deshalb unter `native/` liegen und bei jedem Bau +eingespielt werden — dieselbe Regel, die schon die Team-Kennung und den +Standort-Schlüssel in `ios-signieren.sh` getrieben hat. + +## Einmalig im Apple-Entwicklerportal + +Ohne diese drei Schritte schlägt das Signieren fehl (die App-Gruppe muss +existieren, bevor ein Profil sie enthalten kann): + +1. **Identifiers → App Groups**: `group.app.datametric360` anlegen. +2. **Identifiers → App IDs**: `app.datametric360.teilen` anlegen, Capability + *App Groups* aktivieren, die Gruppe zuordnen. +3. Bei der bestehenden App-ID `app.datametric360` ebenfalls *App Groups* + aktivieren und dieselbe Gruppe zuordnen. + +Danach in Xcode einmal die Profile neu ziehen lassen — oder schlicht +`ios-signieren.sh` laufen lassen, das macht `-allowProvisioningUpdates` +selbst. **Merkposten aus Abschnitt AI:** ein zwischengespeichertes altes +Profil wird stillschweigend weiterverwendet. Ändert sich etwas an den +Berechtigungen, das passende `.mobileprovision` unter +`~/Library/Developer/Xcode/UserData/Provisioning Profiles` löschen und neu +exportieren. + +## Bauen + +```bash +bash scripts/ios-signieren.sh +``` + +Der Schritt „Share-Erweiterung einrichten" läuft dort automatisch, vor dem +Archivieren. + +## Wenn das Skript abbricht + +`ios-teilen-einrichten.mjs` bearbeitet die `project.pbxproj` über das +npm-Paket `xcode`. **Das ist auf keinem Mac erprobt** — geschrieben ohne +Zugriff auf macOS. Bricht es ab, ist nichts halb eingerichtet (es bricht vor +dem Schreiben ab), und dasselbe lässt sich in Xcode von Hand erledigen: + +1. **File → New → Target… → Share Extension**, Name `DataMetric360Share`, + Sprache Swift, „Activate scheme" verneinen. +2. Die von Xcode erzeugten `ShareViewController.swift`, `Info.plist` und das + `MainInterface.storyboard` **löschen** und stattdessen die Dateien aus + `native/teilen/` in das Ziel legen (Storyboard wird nicht gebraucht, die + Erweiterung hat keine eigene Oberfläche). +3. Bundle-ID des Ziels auf `app.datametric360.teilen` setzen. +4. Beide Ziele → **Signing & Capabilities → + Capability → App Groups**, + `group.app.datametric360` anhaken. +5. Ziel `DataMetric360Share` → Build Settings → *Code Signing Entitlements* auf + `DataMetric360Share/DataMetric360Share.entitlements`; Ziel `App` auf + `App/App.entitlements`. +6. Ziel `App` → Build Phases → **Embed App Extensions** muss + `DataMetric360Share.appex` enthalten. +7. `ios/App/App/Info.plist`: URL-Schema `datametric360` unter + `CFBundleURLTypes` eintragen. + +Schritt 7 ist der leiseste: fehlt er, wird der Beleg abgelegt, die App aber +nicht geöffnet — er taucht dann erst auf, wenn man die App das nächste Mal von +Hand startet. + +## Prüfen, ob es wirkt + +1. In Mail ein PDF antippen → **Teilen** → DataMetric360 muss in der Liste + stehen (nur bei genau einem PDF, sonst absichtlich nicht). +2. Antippen → die App kommt nach vorn, springt auf **Tanken**, das Formular ist + offen und liest den Beleg („Beleg wird gelesen …"). +3. Danach ist der Eintrag weg: dieselbe Datei ein zweites Mal zu teilen muss + wieder funktionieren, ein bloßes Wechseln in die App darf den Beleg **nicht** + erneut verarbeiten. + +## Was hier nicht steht + +Android. Der Ablauf ist dort ein anderer (`intent-filter` statt App-Gruppe), +und die Android-Hülle dieses Projekts ist ohnehin nicht in Gebrauch. diff --git a/companion-app/native/App.entitlements b/companion-app/native/App.entitlements new file mode 100644 index 0000000..d5e5b5a --- /dev/null +++ b/companion-app/native/App.entitlements @@ -0,0 +1,15 @@ + + + + + + com.apple.security.application-groups + + group.app.datametric360 + + + diff --git a/companion-app/native/teilen/DataMetric360Share.entitlements b/companion-app/native/teilen/DataMetric360Share.entitlements new file mode 100644 index 0000000..0ae9324 --- /dev/null +++ b/companion-app/native/teilen/DataMetric360Share.entitlements @@ -0,0 +1,16 @@ + + + + + + com.apple.security.application-groups + + group.app.datametric360 + + + diff --git a/companion-app/native/teilen/Info.plist b/companion-app/native/teilen/Info.plist new file mode 100644 index 0000000..8ea1174 --- /dev/null +++ b/companion-app/native/teilen/Info.plist @@ -0,0 +1,53 @@ + + + + + + CFBundleDevelopmentRegion + de + CFBundleDisplayName + DataMetric360 + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsFileWithMaxCount + 1 + NSExtensionActivationSupportsAttachmentsWithMaxCount + 1 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/companion-app/native/teilen/ShareViewController.swift b/companion-app/native/teilen/ShareViewController.swift new file mode 100644 index 0000000..5e94a51 --- /dev/null +++ b/companion-app/native/teilen/ShareViewController.swift @@ -0,0 +1,157 @@ +// +// ShareViewController.swift +// DataMetric360Share +// +// Nimmt ein PDF aus dem iOS-Teilen-Blatt entgegen (typischerweise ein +// Tankbeleg aus Mail) und reicht es an die App weiter. +// +// WIE DIE ÜBERGABE FUNKTIONIERT — UND WARUM SO +// -------------------------------------------- +// Eine Share-Erweiterung ist ein eigener Prozess mit eigenem Dateibereich. +// Sie kann der App nichts direkt geben; der einzige gemeinsame Boden ist +// eine App-Gruppe. Der Weg hier: +// +// 1. Das PDF wird gelesen und Base64-kodiert. +// 2. Es landet zusammen mit seinem Namen in den geteilten +// UserDefaults der Gruppe `group.app.datametric360`. +// 3. Die Erweiterung öffnet die App über ihr eigenes URL-Schema. +// 4. Die App liest den Eintrag beim Start bzw. beim Zurückkehren aus, +// löscht ihn und lädt den Beleg hoch (daten/geteilterBeleg.ts). +// +// Base64 in UserDefaults statt einer Datei im Gruppen-Container: die App +// liest UserDefaults ohnehin schon über @capacitor/preferences, das genau +// diese Gruppe kennt. Eine Datei im Container bräuchte dagegen eigenen +// nativen Code auf der App-Seite — ein zusätzliches Plugin für einen Weg, +// den es sonst nicht gäbe. +// +// Der Preis ist eine Größengrenze: UserDefaults ist für Einstellungen +// gedacht, nicht für Anhänge. GRENZE_BYTES hält das bei 4 MB. Ein +// Tankbeleg liegt bei einigen Dutzend Kilobyte, ein gescanntes Mehrseiten- +// PDF kann darüber liegen — dann sagt die Erweiterung das und der Weg über +// „Datei auswählen" in der App bleibt. +// +// DIESE DATEI IST VERSIONIERT, ios/ NICHT. +// `npx cap add ios` erzeugt den nativen Ordner neu und würde alles darin +// verlieren. scripts/ios-teilen-einrichten.mjs kopiert diese Datei bei +// jedem Bau wieder hinein und hängt das Ziel ins Xcode-Projekt. +// + +import UIKit +import Social +import UniformTypeIdentifiers + +class ShareViewController: UIViewController { + + /// Muss zur App-Gruppe in beiden Entitlements-Dateien passen. + private let gruppe = "group.app.datametric360" + + /// Schlüssel, unter dem die App den Beleg abholt (geteilterBeleg.ts). + private let schluessel = "dm360_geteilter_beleg" + + /// 4 MB. UserDefaults ist kein Dateispeicher — darüber wird abgelehnt, + /// statt das System mit einem Anhang zu belasten, für den es nicht + /// gedacht ist. + private let grenzeBytes = 4 * 1024 * 1024 + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + pdfUebernehmen() + } + + private func pdfUebernehmen() { + guard + let eintrag = (extensionContext?.inputItems as? [NSExtensionItem])?.first, + let anhaenge = eintrag.attachments + else { + return abschliessen() + } + + let pdfTyp = UTType.pdf.identifier + guard let anhang = anhaenge.first(where: { $0.hasItemConformingToTypeIdentifier(pdfTyp) }) + else { + return melden("Kein PDF dabei.") + } + + anhang.loadItem(forTypeIdentifier: pdfTyp, options: nil) { [weak self] wert, _ in + guard let self else { return } + + // Je nach Quelle kommt eine URL, ein Data-Objekt oder nichts + // Brauchbares an — Mail liefert eine URL auf eine temporäre + // Kopie, andere Apps reichen die Bytes direkt. + var daten: Data? + var name = "beleg.pdf" + + if let url = wert as? URL { + daten = try? Data(contentsOf: url) + name = url.lastPathComponent + } else if let roh = wert as? Data { + daten = roh + } + + guard let daten else { + return self.melden("Der Beleg ließ sich nicht lesen.") + } + guard daten.count <= self.grenzeBytes else { + return self.melden( + "Der Beleg ist zu groß für diesen Weg. In der App über „Beleg hochladen" → „Datei auswählen".") + } + + self.ablegen(daten: daten, name: name) + } + } + + private func ablegen(daten: Data, name: String) { + guard let ablage = UserDefaults(suiteName: gruppe) else { + return melden("Die App-Gruppe ist nicht eingerichtet.") + } + // Form wie in geteilterBeleg.ts erwartet. @capacitor/preferences legt + // Werte als String ab, deshalb JSON und kein Dictionary. + let inhalt: [String: Any] = [ + "name": name, + "base64": daten.base64EncodedString(), + "zeit": Int(Date().timeIntervalSince1970 * 1000), + ] + guard + let json = try? JSONSerialization.data(withJSONObject: inhalt), + let text = String(data: json, encoding: .utf8) + else { + return melden("Der Beleg ließ sich nicht übergeben.") + } + ablage.set(text, forKey: schluessel) + + DispatchQueue.main.async { [weak self] in + self?.appOeffnen() + self?.abschliessen() + } + } + + /// Öffnet die Host-App. Eine Erweiterung darf `UIApplication.shared` nicht + /// benutzen, deshalb der Umweg über die Responder-Kette — der einzige von + /// Apple vorgesehene Weg aus einer Erweiterung heraus. + private func appOeffnen() { + guard let ziel = URL(string: "datametric360://beleg") else { return } + var antwort: UIResponder? = self + while let r = antwort { + if let app = r as? UIApplication { + app.open(ziel, options: [:], completionHandler: nil) + return + } + antwort = r.next + } + } + + private func melden(_ text: String) { + DispatchQueue.main.async { [weak self] in + let hinweis = UIAlertController(title: "DataMetric360", message: text, preferredStyle: .alert) + hinweis.addAction(UIAlertAction(title: "Verstanden", style: .default) { _ in + self?.abschliessen() + }) + self?.present(hinweis, animated: true) + } + } + + private func abschliessen() { + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + } +} diff --git a/companion-app/package.json b/companion-app/package.json index 524f2bf..4643734 100644 --- a/companion-app/package.json +++ b/companion-app/package.json @@ -18,8 +18,12 @@ "@aparajita/capacitor-secure-storage": "^8.0.0", "@audi-dash/ui": "*", "@capacitor/android": "^8.5.0", + "@capacitor/app": "^8.1.1", "@capacitor/core": "^8.5.0", + "@capacitor/filesystem": "^8.1.3", "@capacitor/ios": "^8.5.0", + "@capacitor/preferences": "^8.0.1", + "@capacitor/share": "^8.0.1", "@capgo/capacitor-updater": "^8.51.14", "leaflet": "^1.9.4", "react": "^18.3.1", @@ -36,6 +40,7 @@ "jsdom": "^29.1.1", "typescript": "^5.7.2", "vite": "^6.0.7", - "vitest": "^2.1.8" + "vitest": "^2.1.8", + "xcode": "^3.0.1" } } diff --git a/companion-app/scripts/ios-signieren.sh b/companion-app/scripts/ios-signieren.sh index 8ba751a..9f7a076 100755 --- a/companion-app/scripts/ios-signieren.sh +++ b/companion-app/scripts/ios-signieren.sh @@ -75,6 +75,49 @@ if [ ! -d ios ]; then fi npx cap sync ios +# Standort-Berechtigung ins Info.plist eintragen. +# +# Ohne diesen Schluessel zeigt iOS beim Aufruf von navigator.geolocation in +# der WebView (src/screens/Standort.tsx) ueberhaupt keine Rueckfrage: der +# Aufruf scheitert sofort, die App meldet "GPS-Freigabe fehlt", und unter +# Einstellungen > DataMetric360 > Ort fehlt die Auswahl "Beim Verwenden der +# App" - es bleiben nur "Nie" und "Beim Teilen". Nachgewiesen am 2026-08-31 +# an der ausgelieferten App.ipa: kein einziger Location-Schluessel darin. +# +# Warum hier und nicht in Xcode: ios/ ist gitignored und wird von +# `npx cap add ios` aus Capacitors Vorlage neu erzeugt - die kennt keine +# Standort-Schluessel. Ein Eintrag von Hand in Xcode waere beim naechsten +# Erzeugen wieder verschwunden, genau wie es der Team-Kennung oben ergangen +# waere. Deshalb steht er versioniert hier und wird nach jedem `cap sync` +# neu gesetzt. +# +# Nur "WhenInUse": berechnet wird der Abstand zum geparkten Fahrzeug, waehrend +# jemand die Standort-Seite ansieht. Hintergrundortung braucht die App nicht, +# und was sie nicht braucht, soll sie auch nicht erfragen. +# Share-Erweiterung ins Xcode-Projekt haengen. +# +# Aus demselben Grund wie die Team-Kennung und der Standort-Schluessel oben: +# ios/ ist gitignored, also verschwindet jedes von Hand angelegte Ziel beim +# naechsten `npx cap add ios`. Die Quellen liegen versioniert unter native/, +# das Skript spielt sie bei jedem Bau wieder ein. Es ist idempotent - ein +# zweiter Lauf ueber ein fertiges Projekt aendert nichts. +# +# Schlaegt es fehl, bricht der ganze Bau ab: eine App ohne den Eintrag im +# Teilen-Blatt sieht fertig aus und ist es nicht, und der Unterschied faellt +# erst auf dem Geraet auf. +echo "==> Share-Erweiterung einrichten" +node scripts/ios-teilen-einrichten.mjs + +echo "==> Standort-Berechtigung eintragen" +PLIST="ios/App/App/Info.plist" +GRUND="Zeigt auf der Standort-Karte, wie weit du vom geparkten Fahrzeug entfernt bist." +/usr/libexec/PlistBuddy -c "Set :NSLocationWhenInUseUsageDescription $GRUND" "$PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :NSLocationWhenInUseUsageDescription string $GRUND" "$PLIST" +/usr/libexec/PlistBuddy -c "Print :NSLocationWhenInUseUsageDescription" "$PLIST" >/dev/null || { + echo "FEHLER: NSLocationWhenInUseUsageDescription liess sich nicht setzen." >&2 + exit 1 +} + echo "==> Archiv bauen und signieren (Team $TEAM)" rm -rf "$AUSGABE" mkdir -p "$AUSGABE" diff --git a/companion-app/scripts/ios-teilen-einrichten.mjs b/companion-app/scripts/ios-teilen-einrichten.mjs new file mode 100644 index 0000000..04d6311 --- /dev/null +++ b/companion-app/scripts/ios-teilen-einrichten.mjs @@ -0,0 +1,198 @@ +#!/usr/bin/env node +/** + * Hängt die Share-Erweiterung ins erzeugte Xcode-Projekt. + * + * WARUM ES DIESES SKRIPT GIBT + * --------------------------- + * `companion-app/ios/` ist absichtlich gitignored — `npx cap add ios` baut den + * Ordner aus dem Webbündel jederzeit neu. Damit verschwindet aber auch jedes + * Ziel, das man in Xcode von Hand anlegt. Dieselbe Falle, die schon die + * Team-Kennung und den Standort-Schlüssel in `ios-signieren.sh` getrieben hat: + * was den nächsten `cap add` überleben soll, muss versioniert sein und bei + * jedem Bau neu eingespielt werden. + * + * Die Quellen liegen deshalb unter `companion-app/native/`; dieses Skript + * kopiert sie nach `ios/App/` und trägt das Erweiterungsziel in die + * `project.pbxproj` ein. + * + * WAS ES ANLEGT + * ------------- + * - Ziel `DataMetric360Share` (App-Erweiterung, Bundle-ID + * `app.datametric360.teilen`) + * - dessen Quelldateien, Info.plist und Entitlements + * - die App-Gruppe `group.app.datametric360` auf BEIDEN Zielen + * - die Einbettung der Erweiterung in die App (Build-Phase + * „Embed App Extensions") + * - das URL-Schema `datametric360://`, über das die Erweiterung die App holt + * + * IDEMPOTENT + * ---------- + * Ein zweiter Lauf über ein bereits eingerichtetes Projekt ändert nichts und + * meldet das. Wichtig, weil `ios-signieren.sh` bei jedem Bau durchläuft und + * `ios/` nicht immer neu erzeugt wird. + * + * NICHT GETESTET AUF EINEM MAC + * ---------------------------- + * Geschrieben ohne Zugriff auf macOS/Xcode. Die pbxproj-Bearbeitung folgt der + * API des npm-Pakets `xcode`; scheitert sie, bricht das Skript mit einer + * klaren Meldung ab und `docs/SHARE_EXTENSION.md` beschreibt die Handgriffe, + * die dasselbe in Xcode erledigen. Lieber ein lauter Abbruch mit Anleitung + * als ein halb eingerichtetes Projekt. + */ + +import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +import xcode from "xcode" + +const HIER = dirname(fileURLToPath(import.meta.url)) +const APP = dirname(HIER) + +const ZIEL_NAME = "DataMetric360Share" +const BUNDLE_ID = "app.datametric360.teilen" +const GRUPPE = "group.app.datametric360" +const SCHEMA = "datametric360" + +const iosApp = join(APP, "ios", "App") +const pbxPfad = join(iosApp, "App.xcodeproj", "project.pbxproj") +const quelleErw = join(APP, "native", "teilen") +const zielErw = join(iosApp, ZIEL_NAME) + +function melden(text) { + console.log(" " + text) +} + +function abbruch(text) { + console.error("") + console.error("FEHLER: " + text) + console.error("") + console.error("Die Share-Erweiterung ist damit NICHT eingerichtet. Die App selbst") + console.error("baut und läuft weiterhin - nur der Eintrag im Teilen-Blatt fehlt.") + console.error("Von Hand nachziehen: companion-app/docs/SHARE_EXTENSION.md") + process.exit(1) +} + +if (!existsSync(pbxPfad)) { + abbruch(`${pbxPfad} nicht gefunden - erst 'npx cap sync ios' laufen lassen.`) +} + +/* ---------------------------------------------------------------- Dateien */ + +mkdirSync(zielErw, { recursive: true }) +for (const datei of ["ShareViewController.swift", "Info.plist", `${ZIEL_NAME}.entitlements`]) { + copyFileSync(join(quelleErw, datei), join(zielErw, datei)) +} +copyFileSync(join(APP, "native", "App.entitlements"), join(iosApp, "App", "App.entitlements")) +melden(`Quellen nach ios/App/${ZIEL_NAME}/ kopiert`) + +/* ------------------------------------------------------------- URL-Schema */ + +/* Ohne dieses Schema kann die Erweiterung die App nicht holen - sie legt den + Beleg zwar ab, aber niemand merkt es, bis die App das nächste Mal von Hand + geöffnet wird. */ +const appPlistPfad = join(iosApp, "App", "Info.plist") +let appPlist = readFileSync(appPlistPfad, "utf8") +if (!appPlist.includes("CFBundleURLTypes")) { + const eintrag = [ + "\tCFBundleURLTypes", + "\t", + "\t\t", + "\t\t\tCFBundleURLName", + `\t\t\tapp.datametric360`, + "\t\t\tCFBundleURLSchemes", + "\t\t\t", + `\t\t\t\t${SCHEMA}`, + "\t\t\t", + "\t\t", + "\t", + "", + ].join("\n") + const letztes = appPlist.lastIndexOf("") + if (letztes < 0) abbruch("ios/App/App/Info.plist hat keine schließende -Marke.") + appPlist = appPlist.slice(0, letztes) + eintrag + appPlist.slice(letztes + "".length) + writeFileSync(appPlistPfad, appPlist, "utf8") + melden(`URL-Schema ${SCHEMA}:// eingetragen`) +} else { + melden("URL-Schema war bereits eingetragen") +} + +/* ---------------------------------------------------------------- Projekt */ + +const projekt = xcode.project(pbxPfad) +projekt.parseSync() + +const vorhanden = Object.values(projekt.pbxNativeTargetSection()).some( + (z) => z && typeof z === "object" && z.name === ZIEL_NAME, +) +if (vorhanden) { + melden(`Ziel ${ZIEL_NAME} war bereits im Projekt - nichts geändert`) + process.exit(0) +} + +let ziel +try { + ziel = projekt.addTarget(ZIEL_NAME, "app_extension", ZIEL_NAME, BUNDLE_ID) + + // Eigene Build-Phasen: ohne sie hat das Ziel keine Quelldateien und keine + // Ressourcen, und Xcode baut ein leeres Bündel. + projekt.addBuildPhase([], "PBXSourcesBuildPhase", "Sources", ziel.uuid) + projekt.addBuildPhase([], "PBXResourcesBuildPhase", "Resources", ziel.uuid) + projekt.addBuildPhase([], "PBXFrameworksBuildPhase", "Frameworks", ziel.uuid) + + const gruppe = projekt.addPbxGroup( + ["ShareViewController.swift", "Info.plist", `${ZIEL_NAME}.entitlements`], + ZIEL_NAME, + ZIEL_NAME, + ) + // In die Projektwurzel einhängen, sonst taucht die Gruppe in Xcode nicht auf. + const wurzel = Object.keys(projekt.hash.project.objects.PBXGroup).find( + (k) => projekt.hash.project.objects.PBXGroup[k].name === "CustomTemplate", + ) + if (wurzel) projekt.addToPbxGroup(gruppe.uuid, wurzel) + + projekt.addSourceFile("ShareViewController.swift", { target: ziel.uuid }, gruppe.uuid) + + // Einstellungen, die `addTarget` nicht selbst setzt. + const konfigs = projekt.pbxXCBuildConfigurationSection() + for (const schluessel of Object.keys(konfigs)) { + const eintrag = konfigs[schluessel] + if (!eintrag || typeof eintrag !== "object" || !eintrag.buildSettings) continue + const s = eintrag.buildSettings + if (s.PRODUCT_BUNDLE_IDENTIFIER === `"${BUNDLE_ID}"` || s.PRODUCT_NAME === `"${ZIEL_NAME}"`) { + s.INFOPLIST_FILE = `"${ZIEL_NAME}/Info.plist"` + s.CODE_SIGN_ENTITLEMENTS = `"${ZIEL_NAME}/${ZIEL_NAME}.entitlements"` + s.IPHONEOS_DEPLOYMENT_TARGET = "14.0" + s.SWIFT_VERSION = "5.0" + s.TARGETED_DEVICE_FAMILY = `"1,2"` + s.SKIP_INSTALL = "YES" + } + // Die App selbst braucht ihre neue Entitlements-Datei. + if (s.PRODUCT_BUNDLE_IDENTIFIER === `"app.datametric360"`) { + s.CODE_SIGN_ENTITLEMENTS = `"App/App.entitlements"` + } + } + + // Erweiterung in die App einbetten - ohne diese Phase liegt sie zwar im + // Projekt, wandert aber nicht mit ins fertige .ipa. + projekt.addBuildPhase( + [`${ZIEL_NAME}.appex`], + "PBXCopyFilesBuildPhase", + "Embed App Extensions", + projekt.getFirstTarget().uuid, + "app_extension", + ) + + writeFileSync(pbxPfad, projekt.writeSync(), "utf8") +} catch (fehler) { + abbruch(`Das Xcode-Projekt ließ sich nicht bearbeiten: ${fehler.message}`) +} + +melden(`Ziel ${ZIEL_NAME} angelegt und eingebettet`) +melden(`App-Gruppe ${GRUPPE} auf beiden Zielen`) +console.log("") +console.log(" Einmalig im Apple-Entwicklerportal nötig, sonst schlägt das Signieren fehl:") +console.log(` - App-Gruppe ${GRUPPE} anlegen`) +console.log(` - App-ID ${BUNDLE_ID} anlegen und der Gruppe zuordnen`) +console.log(` - App-ID app.datametric360 ebenfalls der Gruppe zuordnen`) +console.log("") diff --git a/companion-app/src/App.tsx b/companion-app/src/App.tsx index c74648d..ed67e95 100644 --- a/companion-app/src/App.tsx +++ b/companion-app/src/App.tsx @@ -1,7 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from "react" +import { App as CapacitorApp } from "@capacitor/app" + import { DataMetricApi, zugangLesen } from "./api" import { DatenAnbieter, useDaten } from "./daten/DatenKontext" +import { geteiltenBelegAbholen, geteiltenBelegSetzen } from "./daten/geteilterBeleg" import { startklarMelden } from "./daten/ota" import { Shell } from "./Shell" import type { Route, SeitenName } from "./navigation" @@ -75,6 +78,35 @@ function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) { void startklarMelden() }, []) + /* Belege aus dem Teilen-Blatt. + + Die Erweiterung legt das PDF ab und oeffnet die App - beim ersten Start + kommt die App dadurch frisch hoch, bei einer schon laufenden App nur + nach vorn. Deshalb beide Faelle: einmal beim Aufbau, und danach bei + jeder Rueckkehr in den Vordergrund. + + Der Beleg wandert in den geteilten Speicher und die App springt auf + Tanken - dort nimmt ihn Tanken.tsx auf und laedt ihn hoch, ueber + genau denselben Weg wie eine von Hand gewaehlte Datei. */ + useEffect(() => { + let abgemeldet = false + + const holen = async () => { + const datei = await geteiltenBelegAbholen() + if (abgemeldet || !datei) return + geteiltenBelegSetzen(datei) + geheZu("fuel") + } + + void holen() + + const horcher = CapacitorApp.addListener("resume", () => void holen()) + return () => { + abgemeldet = true + void horcher.then((h) => h.remove()) + } + }, [geheZu]) + return ( - ) : null} + ) : ( + /* Gegengewicht zum Zurueck-Pfeil. + + Der Titel ist auf Unterseiten mittig gesetzt - aber nur innerhalb + seines eigenen Kastens, und der beginnt erst hinter dem + Zurueck-Pfeil. Ohne ein gleich breites Element auf der rechten + Seite sass die Ueberschrift deshalb um dessen halbe Breite zu + weit rechts: live gemessen 17px Versatz auf "Standort" + (2026-08-31), und zwar auf jeder Unterseite, nicht nur dort. + + Ein leerer Platzhalter statt einer absoluten Positionierung: so + behaelt ein langer Titel weiterhin seinen eigenen Platz und + schiebt sich nicht unter die Knoepfe. */ + ` mit einer + * Blob-Adresse. In der nativen Hülle nicht: **WKWebView ignoriert das + * `download`-Attribut**, der Klick läuft ins Leere und es passiert sichtbar + * nichts — genau so gemeldet („Wechseltermin in Kalender übernehmen klappt + * nicht", 2026-08-31). + * + * Auf dem Gerät geht die Datei deshalb ins temporäre Verzeichnis und von + * dort ins iOS-Teilen-Blatt, aus dem „Zu Kalender hinzufügen" kommt. Das ist + * auch der ehrlichere Ablauf: man sieht, wohin der Termin geht, statt dass + * eine Datei stillschweigend irgendwo landet. */ +import { Capacitor } from "@capacitor/core" +import { Directory, Encoding, Filesystem } from "@capacitor/filesystem" +import { Share } from "@capacitor/share" + function zeilenUmbruch(text: string): string { return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n") } @@ -50,13 +66,37 @@ function hash(text: string): number { return wert } -export function kalenderDateiLaden(titel: string, isoTag: string, beschreibung = ""): void { +function dateiname(titel: string): string { + return `${titel.replace(/[^wäöüÄÖÜß-]+/g, "-").toLowerCase()}.ics` +} + +export async function kalenderDateiLaden( + titel: string, + isoTag: string, + beschreibung = "", +): Promise { const inhalt = kalenderDatei(titel, isoTag, beschreibung) + const name = dateiname(titel) + + if (Capacitor.isNativePlatform()) { + /* Cache statt Documents: der Termin ist im Kalender aufgehoben, sobald er + dort ist - die Zwischendatei muss nicht überleben, und iOS darf den + Cache jederzeit räumen. */ + const geschrieben = await Filesystem.writeFile({ + path: name, + data: inhalt, + directory: Directory.Cache, + encoding: Encoding.UTF8, + }) + await Share.share({ title: titel, url: geschrieben.uri }) + return + } + const blob = new Blob([inhalt], { type: "text/calendar;charset=utf-8" }) const url = URL.createObjectURL(blob) const verweis = document.createElement("a") verweis.href = url - verweis.download = `${titel.replace(/[^\wäöüÄÖÜß-]+/g, "-").toLowerCase()}.ics` + verweis.download = name document.body.appendChild(verweis) verweis.click() verweis.remove() diff --git a/companion-app/src/stile/rahmen.css b/companion-app/src/stile/rahmen.css index a35e8d4..574470b 100644 --- a/companion-app/src/stile/rahmen.css +++ b/companion-app/src/stile/rahmen.css @@ -114,6 +114,18 @@ color: var(--fg); } +/* Spiegelbild des Zurueck-Pfeils auf der rechten Seite, damit der + zentrierte Titel wirklich in der Mitte der Kopfzeile steht und nicht in + der Mitte des Rests. Gleiche Masse, gleicher negativer Aussenabstand - + die 34px, die links vom Pfeil samt Abstand belegt werden, stehen damit + auch rechts. */ +.dm-kopf__gegengewicht { + flex: none; + width: 34px; + height: 34px; + margin-right: -8px; +} + .dm-kopf__aktion { min-width: 44px; height: 44px; diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css index 499f6fb..04f4cb9 100644 --- a/companion-app/src/stile/screens.css +++ b/companion-app/src/stile/screens.css @@ -275,8 +275,12 @@ input[type="date"].dm-eingabe { left: 0; right: 0; bottom: 0; - height: 42%; - background: linear-gradient(to bottom, transparent, var(--canvas) 78%); + /* 26/96 statt 42/78: der Verlauf soll die harte Bildkante ausblenden, + nicht das Fahrzeug. Mit 42% Hoehe und Vollton ab 78% war das untere + Drittel des Fotos praktisch einfarbig - Raeder und Schweller lagen + darunter (gemeldet vom Eigentuemer, 2026-08-31). */ + height: 26%; + background: linear-gradient(to bottom, transparent, var(--canvas) 96%); pointer-events: none; z-index: 1; } @@ -1675,21 +1679,24 @@ button.dm-listenzeile { gezaehlt, das Blatt war auf dem Geraet rund 16px hoeher als noetig und schob sich entsprechend weiter hoch. Am Rechner faellt das nicht auf, dort ist der Einzug 0. */ - padding: 0 20px 18px; + padding: 0 20px 34px; transform: translateY(calc(100% - var(--dm-standort-peek))); transition: transform 0.28s cubic-bezier(0.22, 0.61, 0.36, 1); } -/* Aufgezogen rastet das Blatt auf der mittleren Hoehe ein, nicht auf seiner - Inhaltshoehe. Apple beschreibt genau das als "detents": Hoehen, auf denen - ein Blatt natuerlich zur Ruhe kommt - large ist das voll ausgefahrene - Blatt, medium etwa die Haelfte davon. Vorher war die Rasthoehe schlicht - die Hoehe des Inhalts, dadurch schlossen die Knoepfe unten buendig mit der - Menueleiste ab. Der Ueberschuss liegt unter den Knoepfen und haelt sie von - der Leiste frei. Die eingeklappte Hoehe bleibt unberuehrt: die Verschiebung - rechnet mit 100% der jeweiligen Blatthoehe minus dem Guckwert. */ +/* Aufgezogen bleibt unter den Knoepfen ein fester Abstand stehen, statt dass + sie buendig mit der Menueleiste abschliessen. + + Vorher stand hier min-height:50% - Apples "medium detent". Gemessen ergab + das 89px (Panel) bzw. 76px (App) Luft unter dem letzten Knopf, und das war + sichtbar zu viel: das Blatt zog ueber die Haelfte des Bildschirms auf, + obwohl sein Inhalt dort laengst zu Ende war. Eine Raste ist nur dann + richtig, wenn zwischen ihr und der Inhaltshoehe kein grosses Loch klafft. + + Jetzt bestimmt der Inhalt die Hoehe, und die 34px Fussabstand halten die + Knoepfe von der Leiste frei - hergeleitet aus der Beanstandung, nicht + geraten: 18px waren zu wenig, 76-89px zu viel. */ .dm-standortmenu--offen { transform: translateY(0); - min-height: 50%; } .dm-standortmenu__griffzone { padding: 10px 0 14px; @@ -1853,7 +1860,8 @@ button.dm-listenzeile { } .dm-marke img { - height: 13px; + /* Auf Zeilenhoehe, siehe .dm-markenlogo weiter unten. */ + height: 16px; width: auto; } @@ -1874,3 +1882,58 @@ button.dm-listenzeile { .dm-sd-knoepfe > * { flex: 1; } + +/* --------------------------------------------------- Beleg-Fenster + Deckungsgleich mit .beleg-drop in audi-dashboard.css: gestrichelter Rahmen, + 14px Radius, 28px Innenabstand, mittig, 15px kraeftige Zeile ueber 12.5px + gedaempfter. `--darueber` ist die Rueckmeldung beim Ziehen (nur am Rechner + erreichbar, am Telefon gibt es keine Ziehgeste fuer Dateien). */ +.dm-beleg-drop { + margin-top: var(--sp-3); + border: 1px dashed var(--line-strong); + border-radius: 14px; + padding: 28px var(--sp-4); + text-align: center; + display: flex; + flex-direction: column; + gap: 6px; +} +.dm-beleg-drop strong { + font-size: 15px; + color: var(--fg); +} +.dm-beleg-drop span { + font-size: 12.5px; + color: var(--fg2); +} +.dm-beleg-drop--darueber { + border-color: var(--fg); + border-style: solid; + background: var(--shade); +} + +.dm-beleg-knoepfe { + display: flex; + flex-direction: column; + gap: var(--sp-2); + margin-top: var(--sp-3); +} +/* Solldruck: vier Zeichen ("2,40"), nicht die volle Feldbreite. + Deckungsgleich mit .druckfeld in audi-dashboard.css (58px). Die Klasse + stand hier schon im Markup, hatte aber keine Regel - die Felder nahmen + deshalb die ganze Zeile ein. */ +.ads-feld input.druckfeld { + width: 58px; + min-width: 0; + flex: none; + text-align: right; +} + +/* Markenlogo (Shell u. a.) auf die Hoehe der Feldbeschriftung daneben - + 16px, siehe .ads-feld label. Mit 14px wirkte es neben "SmartDeal aktiv" + zu klein (gemeldet vom Eigentuemer, 2026-08-31). */ +.dm-markenlogo { + height: 16px; + width: auto; + vertical-align: middle; +} diff --git a/companion-app/src/symbole.tsx b/companion-app/src/symbole.tsx index c9bcea1..85c1cae 100644 --- a/companion-app/src/symbole.tsx +++ b/companion-app/src/symbole.tsx @@ -70,16 +70,16 @@ export function SymbolAudi({ groesse }: SymbolProps) { ) } +/* CI-Symbol tour-s statt der frueheren handgezeichneten Route. Eine + Fuellflaeche, keine Kontur - deshalb voll, wie bei SymbolAudi und + SymbolStatistik. */ export function SymbolFahrten({ groesse }: SymbolProps) { return ( - - - - + } /> ) diff --git a/companion-app/src/theme.test.ts b/companion-app/src/theme.test.ts index 5263d9b..b17815b 100644 --- a/companion-app/src/theme.test.ts +++ b/companion-app/src/theme.test.ts @@ -1,23 +1,40 @@ -import { beforeEach, describe, expect, it } from "vitest" +/** + * Der Startwert des Themas wird beim Laden des Moduls **einmal** aus der + * Ablage gelesen und danach im Speicher gehalten — das verlangt + * `useSyncExternalStore`, dessen Momentaufnahme zwischen zwei Änderungen + * stabil sein muss (siehe theme.ts). + * + * Deshalb muss jeder Fall das Modul frisch laden, statt die Ablage zu + * beschreiben und dieselbe Instanz erneut zu fragen. Das ist keine + * Umständlichkeit des Tests, sondern genau das, was in der App passiert: die + * gespeicherte Wahl wirkt beim Start. + */ -import { themeStartwert } from "./theme" +import { beforeEach, describe, expect, it, vi } from "vitest" + +async function startwertNachLaden(): Promise { + vi.resetModules() + const { themeStartwert } = await import("./theme") + return themeStartwert() +} describe("Theme", () => { beforeEach(() => { localStorage.clear() }) - it("startet ohne gespeicherte Wahl im Nachtmodus", () => { - expect(themeStartwert()).toBe("nacht") + it("startet ohne gespeicherte Wahl im Nachtmodus", async () => { + expect(await startwertNachLaden()).toBe("nacht") }) - it("übernimmt eine gespeicherte Wahl", () => { + it("übernimmt eine gespeicherte Wahl", async () => { localStorage.setItem("dm360.theme", "tag") - expect(themeStartwert()).toBe("tag") + expect(await startwertNachLaden()).toBe("tag") }) - it("ignoriert unsinnige gespeicherte Werte", () => { + it("ignoriert unsinnige gespeicherte Werte", async () => { localStorage.setItem("dm360.theme", "irgendwas") - expect(themeStartwert()).toBe("nacht") + expect(await startwertNachLaden()).toBe("nacht") }) + }) diff --git a/companion-app/src/theme.test.tsx b/companion-app/src/theme.test.tsx new file mode 100644 index 0000000..a15aff5 --- /dev/null +++ b/companion-app/src/theme.test.tsx @@ -0,0 +1,73 @@ +/** + * Regression: die Tag/Nacht-Umschaltung blieb wirkungslos. + * + * Jeder Aufruf von `useTheme()` legte bis 2026-08-31 sein eigenes `useState` + * an. Der Schalter in den Einstellungen änderte damit nur seine eigene Kopie; + * die Kopie in `App.tsx`, die `data-theme` setzt, blieb stehen — sichtbar + * wurde die Wahl erst nach einem Neustart der App. Der Boden-Schalter hatte + * denselben Fehler. + * + * Der Test stellt genau diese Konstellation nach: zwei getrennte Komponenten, + * eine schaltet, die andere zeigt an. + */ + +import { fireEvent, render, screen } from "@testing-library/react" +import { beforeEach, describe, expect, it } from "vitest" + +import { useBoden, useTheme } from "./theme" + +function Anzeige() { + const [theme] = useTheme() + const [boden] = useBoden() + return ( +
+ {theme} + {boden ? "an" : "aus"} +
+ ) +} + +function Schalter() { + const [theme, setzeTheme] = useTheme() + const [boden, setzeBoden] = useBoden() + return ( +
+ + +
+ ) +} + +describe("Ansichtseinstellungen, geteilt zwischen Komponenten", () => { + beforeEach(() => { + localStorage.clear() + }) + + it("die Themenwahl der einen Komponente erreicht die andere", () => { + render( + <> + + + , + ) + const vorher = screen.getByTestId("theme").textContent + + fireEvent.click(screen.getByText("Thema")) + + expect(screen.getByTestId("theme").textContent).not.toBe(vorher) + }) + + it("der Boden-Schalter der einen Komponente erreicht die andere", () => { + render( + <> + + + , + ) + const vorher = screen.getByTestId("boden").textContent + + fireEvent.click(screen.getByText("Boden")) + + expect(screen.getByTestId("boden").textContent).not.toBe(vorher) + }) +}) diff --git a/companion-app/src/theme.ts b/companion-app/src/theme.ts index d4a01b8..3782f5a 100644 --- a/companion-app/src/theme.ts +++ b/companion-app/src/theme.ts @@ -1,58 +1,128 @@ /** - * Tag/Nacht-Umschaltung, übernommen aus dem alten Panel (A6 des Design-Audits): - * gespeicherte Wahl schlägt Systemeinstellung, Standard ist "nacht". - * Alle localStorage-Zugriffe sind abgesichert, weil sie im Privatmodus mancher - * Browser werfen. + * Ansichtseinstellungen: Tag/Nacht, Fahrbahn unter dem Fahrzeug, Beschriftung + * der Menüleiste. Übernommen aus dem alten Panel (A6 des Design-Audits): + * gespeicherte Wahl schlägt Systemeinstellung, Standard für das Thema ist + * „nacht". + * + * WARUM HIER EIN GETEILTER SPEICHER STEHT UND NICHT DREIMAL useState + * ------------------------------------------------------------------ + * Bis 2026-08-31 legte jeder dieser Haken sein eigenes `useState` an. Wer ihn + * zweimal aufrief, bekam zwei voneinander unabhängige Kopien desselben Werts: + * + * useTheme() → App.tsx (setzt data-theme), Einstellungen.tsx (der Schalter), + * Typenschild.tsx (zweimal, für die Farbe des Schilds) + * useBoden() → Einstellungen.tsx (der Schalter), Uebersicht.tsx (die Anzeige) + * + * Der Schalter in den Einstellungen änderte damit nur seine eigene Kopie und + * den Speicher der Ablage — die Kopie in App.tsx blieb stehen, `data-theme` + * wechselte nie, und die Umschaltung wirkte erst nach einem Neustart der App. + * Genau so gemeldet: „Tag/Nacht Umschaltung wirkungslos". Der Boden-Schalter + * hatte denselben Fehler, nur ist er weniger auffällig. + * + * `useSyncExternalStore` (in React eingebaut) löst das an der Wurzel: es gibt + * je Einstellung genau einen Wert, jeder Aufrufer sieht denselben, und ein + * Setzen benachrichtigt alle. Das ist der React-eigene Weg für einen Zustand, + * der außerhalb des Komponentenbaums lebt — kein Kontext-Anbieter nötig, und + * die Aufrufstellen bleiben unverändert. + * + * Alle Zugriffe auf die Ablage sind abgesichert: im Privatmodus mancher + * Browser wirft schon das Lesen. */ -import { useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useSyncExternalStore } from "react" export type Theme = "nacht" | "tag" -const SCHLUESSEL = "dm360.theme" - -function gespeichertLesen(): Theme | null { +function ablageLesen(schluessel: string): string | null { try { - const wert = localStorage.getItem(SCHLUESSEL) - return wert === "nacht" || wert === "tag" ? wert : null + return localStorage.getItem(schluessel) } catch { return null } } -function gespeichertSchreiben(theme: Theme): void { +function ablageSchreiben(schluessel: string, wert: string): void { try { - localStorage.setItem(SCHLUESSEL, theme) + localStorage.setItem(schluessel, wert) } catch { - // Privatmodus — die Wahl gilt dann nur für diese Sitzung + // Privatmodus — die Wahl gilt dann nur für diese Sitzung. } } +/** + * Ein Wert, den sich alle Aufrufer teilen. + * + * `wert` wird beim ersten Zugriff aus der Ablage gelesen und danach im + * Speicher gehalten — `useSyncExternalStore` verlangt, dass zwei Aufrufe von + * `stand()` ohne zwischenzeitliche Änderung dasselbe liefern. Bei jedem Mal + * neu aus der Ablage zu lesen genügt dem zwar auch, aber nur zufällig; ein + * gehaltener Wert macht die Zusage explizit. + */ +function einstellung( + schluessel: string, + ausAblage: (roh: string | null) => T, + inAblage: (wert: T) => string, +) { + let wert = ausAblage(ablageLesen(schluessel)) + const horcher = new Set<() => void>() + + return { + stand: () => wert, + horchen: (bei: () => void) => { + horcher.add(bei) + return () => horcher.delete(bei) + }, + setzen: (neu: T) => { + if (neu === wert) return + wert = neu + ablageSchreiben(schluessel, inAblage(neu)) + for (const bei of horcher) bei() + }, + } +} + +const THEMA = einstellung( + "dm360.theme", + (roh) => { + if (roh === "nacht" || roh === "tag") return roh + // Ohne gespeicherte Wahl entscheidet das System; ohne beides: nacht. + const hell = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-color-scheme: light)").matches + return hell ? "tag" : "nacht" + }, + (wert) => wert, +) + +const BODEN = einstellung( + "dm360.boden", + (roh) => roh !== "aus", + (an) => (an ? "an" : "aus"), +) + +const TABBESCHRIFTUNG = einstellung( + "dm360.tabbeschriftung", + (roh) => roh !== "aus", + (an) => (an ? "an" : "aus"), +) + +/** Nur für Tests: setzt den gehaltenen Wert auf den Stand der Ablage zurück. */ export function themeStartwert(): Theme { - const gespeichert = gespeichertLesen() - if (gespeichert) return gespeichert - const hell = - typeof window !== "undefined" && - typeof window.matchMedia === "function" && - window.matchMedia("(prefers-color-scheme: light)").matches - return hell ? "tag" : "nacht" + return THEMA.stand() } export function useTheme(): [Theme, (theme: Theme) => void] { - const [theme, setzeTheme] = useState(themeStartwert) - - const setzen = useCallback((neu: Theme) => { - setzeTheme(neu) - gespeichertSchreiben(neu) - }, []) + const theme = useSyncExternalStore(THEMA.horchen, THEMA.stand, THEMA.stand) + const setzen = useCallback((neu: Theme) => THEMA.setzen(neu), []) // Farbe der Systemleisten mitziehen, damit auf dem Handy kein heller - // Balken über einer dunklen App steht. + // Balken über einer dunklen App steht. Werte des Panels (--canvas in + // audi-dashboard-ios.css); hier standen einmal #161b23/#ffffff aus dem + // Basis-Stylesheet, das im Panel nie greift — die Systemleiste hatte damit + // einen anderen Ton als die App darunter. useEffect(() => { const marke = document.querySelector('meta[name="theme-color"]') - // Werte des Panels (--canvas in audi-dashboard-ios.css). Hier standen noch - // #161b23/#ffffff aus dem Basis-Stylesheet, das im Panel nie greift — - // die Systemleiste hatte damit einen anderen Ton als die App darunter. if (marke) marke.setAttribute("content", theme === "nacht" ? "#0C1014" : "#F2F2F7") }, [theme]) @@ -62,52 +132,21 @@ export function useTheme(): [Theme, (theme: Theme) => void] { /** * Bodenschatten unter dem Fahrzeugbild — im Panel der Schalter „Boden" in den * Einstellungen (`bodenAn`). Dort lebt er nur im Speicher und ist nach einem - * Neuladen wieder an; hier folgt er dem Muster der übrigen Ansichtsschalter - * und bleibt erhalten, Standard wie im Panel: an. + * Neuladen wieder an; hier bleibt er erhalten, Standard wie im Panel: an. */ -const BODEN_SCHLUESSEL = "dm360.boden" - export function useBoden(): [boolean, (an: boolean) => void] { - const [an, setzeAn] = useState(() => { - try { - return localStorage.getItem(BODEN_SCHLUESSEL) !== "aus" - } catch { - return true - } - }) - - const setzen = useCallback((neu: boolean) => { - setzeAn(neu) - try { - localStorage.setItem(BODEN_SCHLUESSEL, neu ? "an" : "aus") - } catch { - // Privatmodus - } - }, []) - + const an = useSyncExternalStore(BODEN.horchen, BODEN.stand, BODEN.stand) + const setzen = useCallback((neu: boolean) => BODEN.setzen(neu), []) return [an, setzen] } /** Sichtbarkeit der Tab-Beschriftungen, ebenfalls dauerhaft gespeichert. */ -const BESCHRIFTUNG_SCHLUESSEL = "dm360.tabbeschriftung" - export function useTabBeschriftung(): [boolean, (an: boolean) => void] { - const [an, setzeAn] = useState(() => { - try { - return localStorage.getItem(BESCHRIFTUNG_SCHLUESSEL) !== "aus" - } catch { - return true - } - }) - - const setzen = useCallback((neu: boolean) => { - setzeAn(neu) - try { - localStorage.setItem(BESCHRIFTUNG_SCHLUESSEL, neu ? "an" : "aus") - } catch { - // Privatmodus - } - }, []) - + const an = useSyncExternalStore( + TABBESCHRIFTUNG.horchen, + TABBESCHRIFTUNG.stand, + TABBESCHRIFTUNG.stand, + ) + const setzen = useCallback((neu: boolean) => TABBESCHRIFTUNG.setzen(neu), []) return [an, setzen] } diff --git a/custom_components/audi_dashboard/einstellungen.py b/custom_components/audi_dashboard/einstellungen.py index eb75b50..7698c52 100644 --- a/custom_components/audi_dashboard/einstellungen.py +++ b/custom_components/audi_dashboard/einstellungen.py @@ -159,94 +159,116 @@ FELDER: list[dict] = [ {"key": "ZUENDUNG_SENSOR", "label": "Zündung/ACC-Status", "gruppe": "fahrterkennung", "hinweis": "on = Fahrt läuft, erkennt Fahrtbeginn/-ende.", "domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": True, + "beispiel": "engine_ignition_or_acc_status", "stichworte": ["zündung", "ignition", "acc", "motor", "engine"]}, {"key": "KM_SENSOR", "label": "Kilometerstand", "gruppe": "fahrterkennung", "hinweis": "Gesamt-Kilometerstand des Fahrzeugs - für Fahrtabschluss-Screening, Reifenzähler und Ölwechsel-Prognose.", "domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False, + "beispiel": "total_vehicle_mileage_read_from_can", "stichworte": ["kilometer", "km", "mileage", "odometer", "tacho", "laufleistung"]}, {"key": "TANK_SENSOR", "label": "Tankfüllstand", "gruppe": "fahrterkennung", "hinweis": "Füllstand in Prozent - für die automatische Tankerkennung.", "domains": ["sensor"], "device_classes": [], "units": ["%"], "liste": False, "pflicht": False, + "beispiel": "fuel_level_in_tank_read_from_can", "stichworte": ["tank", "fuel", "kraftstoff", "füllstand", "level"]}, {"key": "TANK_LITER_SENSOR", "label": "Tankvolumen", "gruppe": "fahrterkennung", "hinweis": "Optional, vom CAN statt aus dem Prozentwert umgerechnet - macht die automatische " "Tankerkennung genauer und liefert eine Vorbelegung für die getankte Menge.", "domains": ["sensor"], "device_classes": ["volume"], "units": ["L", "l"], "liste": False, "pflicht": False, + "beispiel": "can_fuel_volume", "stichworte": ["tankvolumen", "liter", "litres", "fuel volume", "kraftstoffvolumen"]}, {"key": "TANK_DISTANZ_SENSOR", "label": "Strecke seit letzter Tankung", "gruppe": "fahrterkennung", "hinweis": "Optional, vom Fahrzeug selbst berechnet - ersetzt die eigene Kilometerstand-Rechnung " "für die Strecke eines Tankvorgangs.", "domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False, + "beispiel": "calculated_distance_covered_after_the_latest_refueling", "stichworte": ["strecke tankung", "distance refuel", "distance since refueling", "refuelling"]}, {"key": "RANGE_SENSOR", "label": "Reichweite", "gruppe": "uebersicht", "hinweis": "Für die Übersicht - bleibt sie leer, zeigt die Oberfläche \"unbekannt\".", "domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False, + "beispiel": "vehicle_range_read_from_can", "stichworte": ["reichweite", "range"]}, {"key": "BATTERIE_SENSOR", "label": "12V-Batteriespannung", "gruppe": "uebersicht", "hinweis": "Nicht jede Integration liefert diesen Wert - bleibt er leer, zeigt \"Mein Audi\" \"unbekannt\".", "domains": ["sensor"], "device_classes": ["voltage"], "units": ["V"], "liste": False, "pflicht": False, + "beispiel": "external_power_voltage", "stichworte": ["batterie", "battery", "spannung", "volt", "12v"]}, {"key": "AUSSENTEMP_SENSOR", "label": "Außentemperatur", "gruppe": "uebersicht", "hinweis": "CAN-Außentemperatur - optional, ergänzt die Batteriespannungs-Messwertliste um den " "Temperaturwert je Tag.", "domains": ["sensor"], "device_classes": ["temperature"], "units": ["°C", "°F"], "liste": False, - "pflicht": False, "stichworte": ["außentemperatur", "aussentemperatur", "ambient", "temperatur", "temperature"]}, + "pflicht": False, "beispiel": "can_ambient_air_temperature", + "stichworte": ["außentemperatur", "aussentemperatur", "ambient", "temperatur", "temperature"]}, {"key": "REFRESH_BUTTON", "label": "Sofort-Aktualisierung", "gruppe": "uebersicht", "hinweis": "Der von der Integration bereitgestellte Knopf für \"Ziehen zum Aktualisieren\".", "domains": ["button"], "device_classes": [], "units": [], "liste": False, "pflicht": False, + "beispiel": "refresh_now", "stichworte": ["refresh", "aktualisieren", "update", "abrufen", "poll"]}, {"key": "TUER_SENSOREN", "label": "Türen", "gruppe": "sicherheit", "hinweis": "\"aus\"/off = zu. Für \"Sicher abgestellt\" wichtig.", "domains": ["binary_sensor"], "device_classes": ["door"], "units": [], "liste": True, "positionen": POSITIONEN, - "pflicht": False, "stichworte": ["tür", "door"]}, + "pflicht": False, "beispiel": "front_left_door", + "stichworte": ["tür", "door"]}, {"key": "FENSTER_SENSOREN", "label": "Fenster", "gruppe": "sicherheit", "hinweis": "\"aus\"/off = zu.", "domains": ["binary_sensor"], "device_classes": ["window"], "units": [], "liste": True, "positionen": POSITIONEN, - "pflicht": False, "stichworte": ["fenster", "window"]}, + "pflicht": False, "beispiel": "front_left_window", + "stichworte": ["fenster", "window"]}, {"key": "HECKKLAPPE_SENSOR", "label": "Heckklappe", "gruppe": "sicherheit", "hinweis": "\"aus\"/off = zu.", "domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False, + "beispiel": "tailgate", "stichworte": ["heckklappe", "tailgate", "kofferraum", "trunk"]}, {"key": "HAUBE_SENSOR", "label": "Motorhaube", "gruppe": "sicherheit", "hinweis": "\"aus\"/off = zu.", "domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False, + "beispiel": "hood", "stichworte": ["haube", "hood", "bonnet", "motorhaube"]}, {"key": "TUERSCHLOSS_SENSOR", "label": "Türschloss", "gruppe": "sicherheit", "hinweis": "\"aus\"/off = verriegelt. Die Zentralverriegelung schließt alle Türen gemeinsam - " "die Fahrertür genügt für \"Fahrzeug verriegelt\".", "domains": ["binary_sensor"], "device_classes": ["lock"], "units": [], "liste": False, - "pflicht": False, "stichworte": ["türschloss", "schloss", "lock", "verriegelt", "fahrertür"]}, + "pflicht": False, "beispiel": "front_left_door_lock", + "stichworte": ["türschloss", "schloss", "lock", "verriegelt", "fahrertür"]}, {"key": "DACH_SENSOR", "label": "Schiebedach", "gruppe": "sicherheit", "hinweis": "\"aus\"/off = zu.", "domains": ["binary_sensor"], "device_classes": ["window", "door", "opening"], "units": [], "liste": False, "pflicht": False, + "beispiel": "sunroof", "stichworte": ["dach", "schiebedach", "sunroof", "roof"]}, {"key": "LICHT_SENSOR", "label": "Standlicht", "gruppe": "sicherheit", "hinweis": "\"aus\"/off = kein Licht.", "domains": ["binary_sensor"], "device_classes": ["light"], "units": [], "liste": False, "pflicht": False, + "beispiel": "parking_lights", "stichworte": ["licht", "standlicht", "light", "parking lights"]}, {"key": "NAECHSTER_OELWECHSEL_SENSOR", "label": "Nächster Ölwechsel", "gruppe": "uebersicht", "hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit, ergänzt die App-eigene Servicebuch-Prognose.", "domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False, + "beispiel": "oil_change_due", "stichworte": ["ölwechsel", "oil", "service", "change"]}, {"key": "OELWECHSEL_STRECKE_SENSOR", "label": "Nächster Ölwechsel", "gruppe": "uebersicht", "hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.", "domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False, + "beispiel": "oil_change_distance", "stichworte": ["ölwechsel", "oil", "distance", "strecke"]}, {"key": "NAECHSTE_INSPEKTION_SENSOR", "label": "Nächste Inspektion", "gruppe": "uebersicht", "hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit.", "domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False, + "beispiel": "inspection_due", "stichworte": ["inspektion", "inspection", "service"]}, {"key": "INSPEKTION_STRECKE_SENSOR", "label": "Nächste Inspektion", "gruppe": "uebersicht", "hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.", "domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False, + "beispiel": "inspection_distance", "stichworte": ["inspektion", "inspection", "distance", "strecke"]}, {"key": "STANDORT_LAT_SENSOR", "label": "GPS-Standort: Breitengrad", "gruppe": "standort", "hinweis": "Breitengrad als eigener Sensor (z. B. flespi).", "domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False, + "beispiel": "latitude_coordinate_value", "stichworte": ["breitengrad", "latitude", "lat", "standort", "gps", "position", "ortung"]}, {"key": "STANDORT_LON_SENSOR", "label": "GPS-Standort: Längengrad", "gruppe": "standort", "hinweis": "Längengrad als eigener Sensor (z. B. flespi).", "domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False, + "beispiel": "longitude_coordinate_value", "stichworte": ["längengrad", "longitude", "lon", "lng", "standort", "gps", "position", "ortung"]}, ] diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 0b5550c..6171525 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.8.30.20","sha256":"28839ee2ba8d1d703bb688a0124a2930ffb45b0d92acfc05d9c9504b7141c144","bytes":257018,"gebaut":"2026-08-30T22:05:13Z"} \ No newline at end of file +{"version":"2026.8.31.1","sha256":"bd990f600b5bf81ce66105af8d49a77d31a2a4e3b4e5070858c38e1e8d23fcc9","bytes":266016,"gebaut":"2026-08-31T12:23:41Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index d231023..4f5033d 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js index 9acd40d..bd74fbe 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -573,6 +573,82 @@ function tiles() { if (TILE) MAP.removeLayer(TILE); 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 = '
' + + '' + + '' + + '
'; + 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() { await leafletLaden(); const el = ROOT.getElementById("map"); @@ -591,7 +667,10 @@ async function initMap() { ? t.route : [[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); - 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.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] }); @@ -637,6 +716,7 @@ async function initMap() { let USER_POS = null; // {lat, lon, genauigkeit} oder null let USER_POS_TS = 0; 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_KEY = null; let STANDORTMENU_OFFEN = false; @@ -779,6 +859,43 @@ function standortErfassenFallsNoetig() { 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) - wie die Kartenkacheln oben (TILE_URL) ohne eigenen Schlüssel; bei diesem 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 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)); } function standortBeideZeigen() { @@ -986,7 +1107,7 @@ async function initStandortMap() { standortMarkerZeichnen(); standortBeideZeigen(); standortAdresseLaden(); - standortErfassenFallsNoetig(); + standortVerfolgungStarten(); setTimeout(() => SMAP && SMAP.invalidateSize(), 60); } async function initVorschauMap() { @@ -2254,7 +2375,7 @@ function vIdent() {
Modell
${esc(CONFIG.fahrzeugtitel)}
Ausführung
${CONFIG.ausfuehrung ? esc(CONFIG.ausfuehrung) : "noch offen"}
-
Fahrgestellnummer
${esc(CAR.fin)}
+
FIN
${esc(CAR.fin)}
Motor und Leistung
${esc(CAR.details)}
Erstzulassung
${esc(CAR.erstzulassung)}
Kennzeichen
${esc(CONFIG.kennzeichen)}
@@ -2763,7 +2884,7 @@ function vEinst() {
Kraftstoff-Rabatt
+ Shell SmartDeal aktiv
${CAR.smartdeal.aktiv ? `
@@ -3897,7 +4018,10 @@ const ICONS = { /* Wagen von vorn - dasselbe Symbol wie in der myAudi-App (Audi-Symbolsatz, assets/icons/car.svg). Flaeche statt Kontur, deshalb die Klasse "voll". */ audi: '', - trips: '', + /* CI-Symbol tour-s statt der frueheren handgezeichneten Route - dieselbe + Fuellflaeche-statt-Kontur-Behandlung wie "audi" und "stat" (siehe + ICONS_VOLL). */ + trips: '', /* CI-Symbol polls-s statt der vorherigen handgezeichneten Balken - dieselbe Fuellflaeche-statt-Kontur-Behandlung wie "audi" (siehe ICONS_VOLL). Anders als die handgezeichneten Tab-Icons (die im 24er-Raster deutlich Rand @@ -3920,7 +4044,7 @@ const TABS = [ { id: "fuel", label: "Tanken", eyebrow: "", title: "Tanken" }, ]; /* 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" }; const ZURUECK = { trip: "trips", fill: "fuel", service: "audi", reifen: "audi", ident: "audi", @@ -4286,77 +4410,17 @@ const LUPE_SVG = ` 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; -} +/* praefixPaar()/setupAndereIdsOhneDomain()/entitaetIdKurz() sind am 2026-08-31 + entfallen: sie kürzten die Kennung des gerade zugeordneten Sensors für den + Beispieltext im Setup. Der kommt jetzt fest aus dem Feld-Katalog, damit er + sich nicht mehr mit jeder Auswahl ändert - siehe setupFeldzeile(). */ /* Die Art des erwarteten Werts, als kurzes Klammer-Etikett direkt neben der Feld-Überschrift ("Tankfüllstand [%]", "12V-Batteriespannung [V]") - eine feste Eigenschaft der FELD-ROLLE selbst (aus dem FELDER-Katalog, einstellungen.py), unabhängig davon, ob und welcher Sensor gerade zugeordnet ist. Bewusst NICHT der Live-Wert eines Sensors - der gehört - nirgends mehr hin, siehe entitaetIdKurz()'s eigenen Kommentar zur - Kopfzeile. binary_sensor liefert immer den Rohzustand "on"/"off" + nirgends mehr hin. binary_sensor liefert immer den Rohzustand "on"/"off" (unabhängig von device_class - die beeinflusst nur, wie ANDERE HA-Oberflächen ihn anzeigen, nicht den Rohwert, den diese App liest). Bei mehreren möglichen Einheiten (z. B. "L"/"l") die klein geschriebene @@ -4409,18 +4473,33 @@ function entitaetCombo(key, idx) { } function setupFeldzeile(feld) { - // Bei einem Listenfeld (vier Positionen wie Tür-/Fenstersensoren) gibt es - // keinen EINEN zugeordneten Sensor, dessen Kennung sich anzeigen ließe - - // 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. + // Einheit und Beispiel gelten beide für die ganze Feld-Rolle und stehen + // deshalb auch bei Listenfeldern (vier Positionen wie Tür-/Fenstersensoren). const einheit = feldEinheitAnzeige(feld); - const erwartet = feld.liste ? "" : entitaetIdKurz(setupZuordnung[feld.key]); - // Nutzerkorrektur 2026-08-28: kein erfundenes statisches Beispiel mehr (war - // für Reichweite/Sofort-Aktualisierung schlicht falsch) - nur EINE Zeile, - // der tatsächlich zugeordnete Sensor (erwartet), als "(z. B. ...)" - // dargestellt. Der Klammertext selbst in Label-Größe/-Farbe, der Sensorname - // darin in derselben kleinen/grauen Schrift wie die Einheit ("[on/off]"). + /* Das Beispiel steht fest im Feld-Katalog (FELDER in einstellungen.py, + Schlüssel "beispiel") und gehört damit zur FELD-ROLLE - genau wie das + Einheiten-Etikett daneben. + + Vorgeschichte in zwei Schritten: + + 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 ? `(z. B. ${esc(erwartet)})` : ""; @@ -4565,6 +4644,7 @@ function render() { Aenderungspruefung in datenLaden(). */ if (MAP) { MAP.remove(); MAP = null; TILE = null; } 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 (route.name === "standort") initStandortMap().catch(() => {}); 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 // window-Resize-Horcher und laufenden Kachelabrufen am Leben. 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 (TMAP) { TMAP.remove(); TMAP = null; TMAP_TILE = null; } } @@ -5992,6 +6073,7 @@ class AudiDashboardPanel extends HTMLElement {
Übersicht
+
diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-ios.css b/custom_components/audi_dashboard/frontend/audi-dashboard-ios.css index 8274a9a..12fc092 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-ios.css +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-ios.css @@ -105,6 +105,19 @@ html,body{height:100%} .rings svg{height:17px} .profilbtn{display:none} /* 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) .head{align-items: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} /* ---------------- 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); 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)} -.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 ---------------- */ .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), deshalb hier mit !important. */ .rings{display:none !important} + .kopfgegengewicht{display:none !important} /* Nur die Box selbst hoeher setzen, nicht jedes Element mit .carfix: die Klasse haengt auch am und am Platzhalter-
(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 bestehenden left/right-Abstands, ohne die transform-basierte 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 Feld-Überschrift (entitaetIdKurz()) kann ein echter Rohsensorname von Herstellerseite sein, teils 40-60 Zeichen lang diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard.css b/custom_components/audi_dashboard/frontend/audi-dashboard.css index 30c0cb1..85e9d6d 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard.css +++ b/custom_components/audi_dashboard/frontend/audi-dashboard.css @@ -599,8 +599,12 @@ button.leaf:active { background: var(--tile-2); } .szene.bleed::before { content: ""; position: absolute; left: 0; right: 0; bottom: 0; - height: 42%; - background: linear-gradient(to bottom, transparent, var(--canvas) 78%); + /* 26/96 statt 42/78: der Verlauf soll die harte Bildkante ausblenden, + nicht das Fahrzeug. Mit 42% Hoehe und Vollton ab 78% war das untere + Drittel des Fotos praktisch einfarbig - Raeder und Schweller lagen + darunter (gemeldet vom Eigentuemer, 2026-08-31). */ + height: 26%; + background: linear-gradient(to bottom, transparent, var(--canvas) 96%); pointer-events: none; z-index: 1; } @@ -668,7 +672,9 @@ button.leaf:active { background: var(--tile-2); } .marke { display: inline-flex; align-items: center; gap: 6px; } .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.logo-fehlt img { display: none; } .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 schob sich entsprechend weiter hoch. Am Rechner faellt das nicht auf, dort ist der Einzug 0. */ - padding: 0 20px 18px; + padding: 0 20px 34px; transform: translateY(calc(100% - var(--standort-peek))); transition: transform .28s cubic-bezier(.22,.61,.36,1); } -/* Aufgezogen rastet das Blatt auf der mittleren Hoehe ein, nicht auf seiner - Inhaltshoehe. Apple beschreibt genau das als "detents": Hoehen, auf denen - ein Blatt natuerlich zur Ruhe kommt - large ist das voll ausgefahrene - Blatt, medium etwa die Haelfte davon. Vorher war die Rasthoehe schlicht - die Hoehe des Inhalts, dadurch schlossen die Knoepfe unten buendig mit der - Menueleiste ab. Der Ueberschuss liegt unter den Knoepfen und haelt sie von - der Leiste frei. Die eingeklappte Hoehe bleibt unberuehrt: die Verschiebung - rechnet mit 100% der jeweiligen Blatthoehe minus dem Guckwert. */ -.standortmenu.offen { transform: translateY(0); min-height: 50%; } +/* Aufgezogen bleibt unter den Knoepfen ein fester Abstand stehen, statt dass + sie buendig mit der Menueleiste abschliessen. + + Vorher stand hier min-height:50% - Apples "medium detent". Gemessen ergab + das 89px (Panel) bzw. 76px (App) Luft unter dem letzten Knopf, und das war + sichtbar zu viel: das Blatt zog ueber die Haelfte des Bildschirms auf, + obwohl sein Inhalt dort laengst zu Ende war. Eine Raste ist nur dann + richtig, wenn zwischen ihr und der Inhaltshoehe kein grosses Loch klafft. + + Jetzt bestimmt der Inhalt die Hoehe, und die 34px Fussabstand halten die + Knoepfe von der Leiste frei - hergeleitet aus der Beanstandung, nicht + geraten: 18px waren zu wenig, 76-89px zu viel. */ +.standortmenu.offen { transform: translateY(0); } .standortmenu-griffzone { padding: 10px 0 14px; touch-action: none; cursor: grab; position: relative; } /* Der Griff ist jetzt ein Knopf (Tastaturzugang zum Menue). Die Knopf- 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; } /* ------------------------------------------------------- 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 { position: absolute; z-index: 1001; - background: var(--tile-2); - border-radius: 14px; - padding: var(--sp-2); + left: 10px; right: 10px; + bottom: max(10px, env(safe-area-inset-bottom)); + top: auto; + transform: none; + background: var(--tile-deckend, var(--canvas)); + border-radius: 16px; + overflow: hidden; + padding: 0; display: flex; flex-direction: column; - min-width: 160px; + animation: sheet-rein .24s cubic-bezier(.22,.61,.36,1); } -.bildmenu.rad { top: 90px; left: 22px; } -/* "Bild der Übersicht": das Vorschaubild füllt die ganze Kachelbreite, seine - Unterkante liegt daher immer nah an der Kachel-Unterkante - das Menü von - dort aus verankern statt mit einem festen top-Wert, der bei jedem - Bildseitenverhältnis neu nachgemessen werden müsste. */ -.bildmenu.ansicht { bottom: var(--sp-5); left: 50%; transform: translateX(-50%); } +/* Die beiden Lage-Varianten sind gegenstandslos geworden - ein Blatt + kommt immer von unten. Die Klassen bleiben im Markup stehen, damit die + Aufrufstellen unberührt bleiben; hier tragen sie nichts mehr bei. */ +.bildmenu.rad, .bildmenu.ansicht { left: 10px; right: 10px; top: auto; transform: none; } .bildmenu button { display: block; width: 100%; text-align: left; background: none; border: none; - color: var(--fg); font-family: inherit; font-size: 15px; - padding: 13px 10px; min-height: 46px; border-radius: 10px; + color: var(--fg); font-family: inherit; font-size: 16px; + padding: 16px 18px; min-height: 52px; border-radius: 0; + border-bottom: 1px solid var(--line); cursor: pointer; } +.bildmenu button:last-child { border-bottom: none; } .bildmenu button:active { background: var(--shade); } .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 { position: absolute; z-index: 1001; - left: var(--sp-5); right: var(--sp-5); top: 60px; - background: var(--tile-2); - border-radius: 14px; - padding: var(--sp-4); + left: 0; right: 0; bottom: 0; top: auto; + background: var(--tile-deckend, var(--canvas)); + border-radius: 16px 16px 0 0; + 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-catcher { @@ -1129,15 +1163,20 @@ button.leaf:active { background: var(--tile-2); } background: rgba(0,0,0,.45); animation: sheet-fade .18s ease-out; } +/* Formularblatt (siehe Kasten oben). Traegt drei Fenster: Beleg + hochladen, Daten importieren, Datensatz sichern/laden. */ .beleg-popup { position: absolute; z-index: 1001; - left: var(--sp-5); right: var(--sp-5); - top: 50%; transform: translateY(-50%); - /* Wie .setup-popup: --tile-2 ist im Nacht-Theme transluzent, ein frei - schwebendes Fenster braucht einen blickdichten Grund. */ + left: 0; right: 0; bottom: 0; + top: auto; transform: none; + max-height: 88%; + 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)); - border-radius: var(--r-tile); - padding: var(--sp-4); + border-radius: 16px 16px 0 0; + padding: var(--sp-4) 20px max(20px, env(safe-area-inset-bottom)); + animation: blatt-hoch .28s cubic-bezier(.22,.61,.36,1); } .beleg-drop { margin-top: var(--sp-3); diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 6897cdb..d766cee 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -1,7 +1,7 @@ { "domain": "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", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "codeowners": ["@paul"], diff --git a/design-system/src/components/ImagePlaceholder/ImagePlaceholder.tsx b/design-system/src/components/ImagePlaceholder/ImagePlaceholder.tsx index b33ba15..b0e1a84 100644 --- a/design-system/src/components/ImagePlaceholder/ImagePlaceholder.tsx +++ b/design-system/src/components/ImagePlaceholder/ImagePlaceholder.tsx @@ -33,18 +33,34 @@ export function ImagePlaceholder({ size = "default", className, }: 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(null); const classes = ["ads-bildbox"]; if (size === "mini") classes.push("ads-bildbox--mini"); if (size === "round") classes.push("ads-bildbox--round"); if (className) classes.push(className); - const showPlaceholder = !src || failed; + const showPlaceholder = !src || fehlgeschlagen === src; return (
{!showPlaceholder && ( - {alt} setFailed(true)} /> + {alt} setzeFehlgeschlagen(src)} /> )} {showPlaceholder && (
diff --git a/design-system/src/components/Pill/Pill.css b/design-system/src/components/Pill/Pill.css index 7a56314..b7636a6 100644 --- a/design-system/src/components/Pill/Pill.css +++ b/design-system/src/components/Pill/Pill.css @@ -18,3 +18,15 @@ color: var(--red); border-color: var(--red); } + +/* Als Knopf: die Optik bleibt exakt die der Anzeige-Pille, nur die + Browser-Vorgaben fuer + ); + } return {children}; } diff --git a/design-system/src/components/Popup/Popup.tsx b/design-system/src/components/Popup/Popup.tsx index 5093c97..d1e1a1b 100644 --- a/design-system/src/components/Popup/Popup.tsx +++ b/design-system/src/components/Popup/Popup.tsx @@ -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; } diff --git a/design-system/src/components/Sheet/ActionSheet.tsx b/design-system/src/components/Sheet/ActionSheet.tsx new file mode 100644 index 0000000..0cf2050 --- /dev/null +++ b/design-system/src/components/Sheet/ActionSheet.tsx @@ -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( + <> +
+
+
+ {(title || description) && ( +
+ {title && {title}} + {description && {description}} +
+ )} + {children &&
{children}
} + {actions.map((aktion, i) => ( + + ))} +
+ {cancelLabel !== null && ( + + )} +
+ , + target, + ); +} diff --git a/design-system/src/components/Sheet/Sheet.css b/design-system/src/components/Sheet/Sheet.css new file mode 100644 index 0000000..ffa3229 --- /dev/null +++ b/design-system/src/components/Sheet/Sheet.css @@ -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); +} diff --git a/design-system/src/components/Sheet/Sheet.tsx b/design-system/src/components/Sheet/Sheet.tsx new file mode 100644 index 0000000..8ccf45b --- /dev/null +++ b/design-system/src/components/Sheet/Sheet.tsx @@ -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( + <> +
+
+
+ , + target, + ); +} diff --git a/design-system/src/components/Sheet/index.ts b/design-system/src/components/Sheet/index.ts new file mode 100644 index 0000000..4aa5dbb --- /dev/null +++ b/design-system/src/components/Sheet/index.ts @@ -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"; diff --git a/design-system/src/index.ts b/design-system/src/index.ts index d7f7924..54e41a6 100644 --- a/design-system/src/index.ts +++ b/design-system/src/index.ts @@ -46,6 +46,9 @@ export type { SwipeRowProps } from "./components/SwipeRow"; export { TabBar } 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 type { PopupProps, PopupMenuItemProps, PopupAnchor } from "./components/Popup"; diff --git a/design-system/src/styles.css b/design-system/src/styles.css index 22a55c1..d1f10bf 100644 --- a/design-system/src/styles.css +++ b/design-system/src/styles.css @@ -18,5 +18,6 @@ @import "./components/Accordion/Accordion.css"; @import "./components/SwipeRow/SwipeRow.css"; @import "./components/TabBar/TabBar.css"; +@import "./components/Sheet/Sheet.css"; @import "./components/Popup/Popup.css"; @import "./components/ImagePlaceholder/ImagePlaceholder.css"; diff --git a/package-lock.json b/package-lock.json index 90bd351..72c80f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,8 +19,12 @@ "@aparajita/capacitor-secure-storage": "^8.0.0", "@audi-dash/ui": "*", "@capacitor/android": "^8.5.0", + "@capacitor/app": "^8.1.1", "@capacitor/core": "^8.5.0", + "@capacitor/filesystem": "^8.1.3", "@capacitor/ios": "^8.5.0", + "@capacitor/preferences": "^8.0.1", + "@capacitor/share": "^8.0.1", "@capgo/capacitor-updater": "^8.51.14", "leaflet": "^1.9.4", "react": "^18.3.1", @@ -37,7 +41,8 @@ "jsdom": "^29.1.1", "typescript": "^5.7.2", "vite": "^6.0.7", - "vitest": "^2.1.8" + "vitest": "^2.1.8", + "xcode": "^3.0.1" } }, "design-system": { @@ -516,6 +521,18 @@ "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": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.5.0.tgz", @@ -534,6 +551,30 @@ "@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": { "version": "8.51.14", "resolved": "https://registry.npmjs.org/@capgo/capacitor-updater/-/capacitor-updater-8.51.14.tgz",