Teilen-Blatt, HIG-Blaetter, geteilte Ansichtseinstellungen (2026.8.31.1)

Share-Erweiterung: ein PDF aus Mail landet ueber Teilen -> DataMetric360 als
Tankbeleg. Alles Native liegt versioniert unter companion-app/native/, weil
ios/ gitignored ist und npx cap add ios es sonst wieder frisst;
scripts/ios-teilen-einrichten.mjs haengt es bei jedem Bau ins Xcode-Projekt
und ist in ios-signieren.sh eingehaengt. Der pbxproj-Teil ist auf keinem Mac
erprobt - er bricht vor dem Schreiben ab, und docs/SHARE_EXTENSION.md
beschreibt dieselben Handgriffe fuer Xcode.

"In den Kalender uebernehmen" ging in der App nie: WKWebView ignoriert das
download-Attribut, der Klick lief ins Leere. Auf dem Geraet jetzt
@capacitor/filesystem plus @capacitor/share.

useTheme/useBoden legten je Aufrufer eigenen Zustand an - fuenf Kopien. Der
Schalter aenderte nur seine eigene, data-theme in App.tsx blieb stehen, die
Tag/Nacht-Umschaltung wirkte erst nach einem Neustart. Jetzt ein Speicher je
Einstellung ueber useSyncExternalStore. Zwei Regressionstests, gegen den
alten Stand als fehlschlagend nachgewiesen.

Alle Popups nach Apple HIG: neue Bausteine Sheet und ActionSheet, blickdicht
und mit abgedunkeltem Schleier, in beiden Codebasen. Popup portalierte nach
document.body - ausserhalb von [data-theme] - und war deshalb im Tagmodus
fast unsichtbar; Portal zielt jetzt auf .ads-root.

ImagePlaceholder merkte sich "fehlgeschlagen" ohne die Adresse: nach einem
leeren Galerieplatz zeigten auch die mit Foto nur noch den Platzhalter.
Vier Regressionstests, ebenfalls gegen den alten Stand geprueft.

Bildadressen tragen jetzt einen Cache-Brecher - HA liefert /local/ mit 31
Tagen Cache-Vorgabe aus, im Panel eingefuegte Radfotos erschienen in der App
deshalb nicht.

uebersichtsbild: Panel speichert den Namen, die App verglich gegen den
Dateinamen - der Vergleich traf nie zu. Kanonisch ist der Name.

Weiter: Navigationspfeil statt gleichschenkligem Dreieck auf der Streckenlinie
(die Kerbe unterscheidet Kopf und Ende), CI-Symbol tour-s als Fahrten-Icon,
Datumsfelder zeigen ihren Wert ohne erstes Antippen, feste Beispielnamen im
Setup, Kopf-Gegengewicht fuer mittige Titel, Standort-Blatt mit festem
Fussabstand, NSLocationWhenInUseUsageDescription, watchPosition fuer die
Live-Ortung, Art-Pille wieder als Knopf, Zwischenablage fuer Tankbelege.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 14:24:52 +02:00
parent 26f72930c3
commit b1411625aa
58 changed files with 2846 additions and 368 deletions
+32
View File
@@ -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 (
<Shell
route={route}
+15 -1
View File
@@ -221,7 +221,21 @@ export function Shell({
>
<VierRinge hoehe={17} />
</button>
) : null}
) : (
/* Gegengewicht zum Zurueck-Pfeil.
Der Titel ist auf Unterseiten mittig gesetzt - aber nur innerhalb
seines eigenen Kastens, und der beginnt erst hinter dem
Zurueck-Pfeil. Ohne ein gleich breites Element auf der rechten
Seite sass die Ueberschrift deshalb um dessen halbe Breite zu
weit rechts: live gemessen 17px Versatz auf "Standort"
(2026-08-31), und zwar auf jeder Unterseite, nicht nur dort.
Ein leerer Platzhalter statt einer absoluten Positionierung: so
behaelt ein langer Titel weiterhin seinen eigenen Platz und
schiebt sich nicht unter die Knoepfe. */
<span className="dm-kopf__gegengewicht" aria-hidden="true" />
)}
</header>
)
@@ -0,0 +1,95 @@
/**
* Die Auswertung eines geteilten Belegs — der Teil, der ohne Gerät prüfbar
* ist. Das Ablegen selbst passiert nativ (ShareViewController.swift) und
* kann hier nicht nachgestellt werden; geprüft wird, was die App mit dem
* abgelegten Eintrag macht.
*
* `@capacitor/core` und `@capacitor/preferences` werden ersetzt: das eine
* meldet sonst „kein natives Gerät" und die Abholung steigt sofort aus, das
* andere greift auf eine native Ablage zu, die es hier nicht gibt.
*/
import { beforeEach, describe, expect, it, vi } from "vitest"
const ablage = new Map<string, string>()
vi.mock("@capacitor/core", () => ({
Capacitor: { isNativePlatform: () => true },
}))
vi.mock("@capacitor/preferences", () => ({
Preferences: {
configure: vi.fn(async () => {}),
get: vi.fn(async ({ key }: { key: string }) => ({ value: ablage.get(key) ?? null })),
remove: vi.fn(async ({ key }: { key: string }) => {
ablage.delete(key)
}),
},
}))
const SCHLUESSEL = "dm360_geteilter_beleg"
/** Ein winziges, gültiges PDF als Base64 — Inhalt ist hier gleichgültig. */
const PDF_BASE64 = btoa("%PDF-1.4\n%%EOF\n")
function ablegen(eintrag: Record<string, unknown>) {
ablage.set(SCHLUESSEL, JSON.stringify(eintrag))
}
describe("geteilten Beleg abholen", () => {
beforeEach(() => {
ablage.clear()
vi.resetModules()
})
it("liefert nichts, wenn keiner wartet", async () => {
const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
expect(await geteiltenBelegAbholen()).toBeNull()
})
it("baut aus dem Eintrag eine PDF-Datei", async () => {
ablegen({ name: "tankbeleg.pdf", base64: PDF_BASE64, zeit: Date.now() })
const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
const datei = await geteiltenBelegAbholen()
expect(datei).not.toBeNull()
expect(datei!.name).toBe("tankbeleg.pdf")
expect(datei!.type).toBe("application/pdf")
expect(datei!.size).toBeGreaterThan(0)
})
it("räumt den Eintrag weg, damit derselbe Beleg nicht zweimal ankommt", async () => {
ablegen({ name: "a.pdf", base64: PDF_BASE64, zeit: Date.now() })
const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
expect(await geteiltenBelegAbholen()).not.toBeNull()
expect(await geteiltenBelegAbholen()).toBeNull()
})
it("verwirft einen liegengebliebenen Eintrag", async () => {
// Älter als zehn Minuten: das Öffnen der App aus der Erweiterung heraus
// ist damals fehlgeschlagen. Ihn jetzt als neuen Beleg zu behandeln wäre
// eine Überraschung, keine Hilfe.
ablegen({ name: "alt.pdf", base64: PDF_BASE64, zeit: Date.now() - 11 * 60 * 1000 })
const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
expect(await geteiltenBelegAbholen()).toBeNull()
})
it("verwirft unlesbaren Inhalt, statt daran hängenzubleiben", async () => {
ablage.set(SCHLUESSEL, "{kein json")
const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
expect(await geteiltenBelegAbholen()).toBeNull()
// Auch der kaputte Eintrag ist weg - sonst liefe er bei jedem Start neu auf.
expect(ablage.has(SCHLUESSEL)).toBe(false)
})
it("nimmt einen Standardnamen, wenn keiner mitkam", async () => {
ablegen({ base64: PDF_BASE64, zeit: Date.now() })
const { geteiltenBelegAbholen } = await import("./geteilterBeleg")
expect((await geteiltenBelegAbholen())!.name).toBe("beleg.pdf")
})
})
+122
View File
@@ -0,0 +1,122 @@
/**
* Belege, die über das iOS-Teilen-Blatt hereinkommen.
*
* Die Gegenseite ist `native/teilen/ShareViewController.swift`: sie legt das
* geteilte PDF Base64-kodiert in den UserDefaults der App-Gruppe
* `group.app.datametric360` ab und öffnet danach die App über
* `datametric360://beleg`.
*
* `@capacitor/preferences` liest genau diese UserDefaults, sobald man ihm die
* Gruppe nennt — deshalb braucht es hier kein eigenes Plugin. Ohne die Gruppe
* läse es den privaten Bereich der App und fände nie etwas.
*
* ABGEHOLT WIRD GENAU EINMAL
* --------------------------
* Der Eintrag wird nach dem Lesen gelöscht. Bliebe er stehen, käme derselbe
* Beleg bei jedem Zurückkehren in die App erneut — und weil der Upload beim
* Backend einen Vorgang anlegt, wären das echte Doppeleinträge, keine bloße
* Anzeigewiederholung.
*
* Der Zeitstempel ist die zweite Sicherung: ein Eintrag, der älter ist als
* HOECHSTALTER_MS, wird verworfen statt verarbeitet. Er kann nur entstehen,
* wenn das Öffnen der App aus der Erweiterung heraus fehlgeschlagen ist; ihn
* Tage später beim nächsten Start als „neuen" Beleg zu behandeln, wäre eine
* Überraschung, keine Hilfe.
*/
import { Capacitor } from "@capacitor/core"
import { Preferences } from "@capacitor/preferences"
/** Muss zu `gruppe` in ShareViewController.swift passen. */
const GRUPPE = "group.app.datametric360"
/** Muss zu `schluessel` in ShareViewController.swift passen. */
const SCHLUESSEL = "dm360_geteilter_beleg"
/** Zehn Minuten. Länger her heißt: das Öffnen der App hat nicht geklappt. */
const HOECHSTALTER_MS = 10 * 60 * 1000
let eingerichtet = false
async function gruppeEinrichten(): Promise<void> {
if (eingerichtet) return
eingerichtet = true
try {
await Preferences.configure({ group: GRUPPE })
} catch (fehler) {
// Auf Android und im Browser gibt es keine App-Gruppe; das ist kein
// Fehler, dort kommt über diesen Weg nur nie etwas an.
console.warn("Geteilter Beleg: Gruppe nicht einrichtbar", fehler)
}
}
function base64ZuDatei(base64: string, name: string): File {
const roh = atob(base64)
const bytes = new Uint8Array(roh.length)
for (let i = 0; i < roh.length; i++) bytes[i] = roh.charCodeAt(i)
return new File([bytes], name, { type: "application/pdf" })
}
/**
* Holt einen wartenden Beleg ab und räumt ihn weg. `null`, wenn keiner da ist
* — der Normalfall bei jedem gewöhnlichen App-Start.
*/
export async function geteiltenBelegAbholen(): Promise<File | null> {
if (!Capacitor.isNativePlatform()) return null
await gruppeEinrichten()
let roh: string | null = null
try {
roh = (await Preferences.get({ key: SCHLUESSEL })).value
} catch (fehler) {
console.warn("Geteilter Beleg: nicht lesbar", fehler)
return null
}
if (!roh) return null
// Erst wegräumen, dann auswerten: ein unlesbarer Eintrag darf nicht bei
// jedem Start erneut auflaufen.
try {
await Preferences.remove({ key: SCHLUESSEL })
} catch {
// Bleibt er stehen, fängt ihn beim nächsten Mal die Altersprüfung ab.
}
try {
const eintrag = JSON.parse(roh) as { name?: string; base64?: string; zeit?: number }
if (!eintrag.base64) return null
if (typeof eintrag.zeit === "number" && Date.now() - eintrag.zeit > HOECHSTALTER_MS) {
return null
}
return base64ZuDatei(eintrag.base64, eintrag.name || "beleg.pdf")
} catch (fehler) {
console.warn("Geteilter Beleg: unlesbarer Eintrag verworfen", fehler)
return null
}
}
/* ------------------------------------------------------------- Übergabe
Zwischen dem Abholen (App.tsx, beim Start und bei jeder Rückkehr) und dem
Verarbeiten (Tanken.tsx) liegt ein Bildschirmwechsel. Ein kleiner geteilter
Speicher nach dem Muster von theme.ts überbrückt ihn — ein Zustand, der
außerhalb des Komponentenbaums lebt und den beide Seiten sehen. */
let wartend: File | null = null
const horcher = new Set<() => void>()
export function geteiltenBelegSetzen(datei: File | null): void {
wartend = datei
for (const bei of horcher) bei()
}
export function geteiltenBelegStand(): File | null {
return wartend
}
export function geteiltenBelegHorchen(bei: () => void): () => void {
horcher.add(bei)
return () => {
horcher.delete(bei)
}
}
+2 -1
View File
@@ -133,7 +133,8 @@ export function profilZuEinstellungen(profil: Profil): Einstellungen {
fahrzeugzusatz: text(fahrzeug, "zusatz"),
fahrzeuguntertitel: text(fahrzeug, "untertitel"),
kennzeichen: text(fahrzeug, "kennzeichen"),
startbild: text(einst, "uebersichtsbild", "seitenansicht.webp"),
// Der Name der Ansicht, nicht der Dateiname - so speichert es das Panel.
startbild: text(einst, "uebersichtsbild", "Seitenansicht"),
tankvolumen: zahl(fahrzeug, "tankvolumen_liter") ?? 58,
nachtVon: text(einst, "nacht_von", "22:00"),
nachtBis: text(einst, "nacht_bis", "06:00"),
+73
View File
@@ -0,0 +1,73 @@
/**
* Beleg-PDF aus der Zwischenablage oder einer Dateiliste.
*
* Übernommen aus dem Panel (`belegAusZwischenablage()`, `istPdf()`,
* `zwischenablageKnopfSinnvoll()` in audi-dashboard-app.js) — dort gibt es
* diesen Weg seit längerem, in der App fehlte er ganz: der Beleg-Knopf öffnete
* direkt die Dateiauswahl, und ein aus der Mail kopiertes PDF ließ sich
* überhaupt nicht einfügen (gemeldet vom Eigentümer, 2026-08-31).
*
* WARUM DER KNOPF NUR AUF TIPPGERÄTEN ERSCHEINT
* ---------------------------------------------
* `navigator.clipboard.read()` gibt aus Datenschutzgründen nur Text, HTML und
* Bilder heraus. Eine im Explorer oder Finder kopierte Datei taucht dort nie
* auf — am Rechner scheitert der Weg also zwangsläufig, und ein Knopf, der
* immer fehlschlägt, ist schlimmer als kein Knopf. Dort bleiben Ziehen und
* Dateiauswahl.
*
* Auf iOS ist es umgekehrt der Hauptweg: das PDF kommt aus Mail, wird dort
* kopiert, und das System zeigt beim Lesen seine eigene Einfügen-Abfrage.
*/
/** Nicht nur auf den MIME-Typ verlassen: eine aus dem Explorer/Finder kopierte
oder gezogene Datei kommt je nach Browser und Quelle mit leerem `type` an. */
export function istPdf(datei: File | null | undefined): boolean {
if (!datei) return false
return datei.type === "application/pdf" || /\.pdf$/i.test(datei.name || "")
}
/** Nimmt das erste PDF aus einer DataTransfer-/Dateiliste. */
export function pdfAusDateiliste(dateien: FileList | File[] | null | undefined): File | null {
return [...(dateien ?? [])].find(istPdf) ?? null
}
export class ZwischenablageFehler extends Error {}
/**
* Holt ein PDF aus der Zwischenablage.
*
* Wirft mit einem Text, der zur jeweiligen Ursache die richtige Abhilfe nennt —
* "bitte Datei auswählen" hilft nicht weiter, wenn schlicht kein PDF kopiert
* wurde, und "erneut kopieren" nicht, wenn der Browser die Zwischenablage gar
* nicht herausgibt.
*/
export async function pdfAusZwischenablage(): Promise<File> {
if (!navigator.clipboard?.read) {
throw new ZwischenablageFehler(
'Dieser Browser gibt die Zwischenablage nicht heraus - bitte "Datei auswählen".',
)
}
let eintraege: ClipboardItems
try {
eintraege = await navigator.clipboard.read()
} catch {
// Verweigerte Freigabe, abgebrochene System-Abfrage, unsicherer Ursprung.
throw new ZwischenablageFehler('Kein Zugriff auf die Zwischenablage - bitte "Datei auswählen".')
}
for (const eintrag of eintraege) {
const typ = eintrag.types.find((t) => t === "application/pdf")
if (typ) {
const blob = await eintrag.getType(typ)
// Ein Name muss sein: das Backend legt den Beleg unter ihm ab.
return new File([blob], "beleg.pdf", { type: "application/pdf" })
}
}
throw new ZwischenablageFehler(
'In der Zwischenablage liegt kein PDF - bitte den Beleg erneut kopieren oder "Datei auswählen".',
)
}
/** Tippgerät? Nur dort wird der Einfügen-Knopf angeboten (Begründung oben). */
export function zwischenablageKnopfSinnvoll(): boolean {
return typeof window !== "undefined" && !!window.matchMedia?.("(pointer: coarse)").matches
}
+129
View File
@@ -0,0 +1,129 @@
/**
* „Beleg hochladen" — Vorlage: `vBelegPopup()` im Panel.
*
* Bis 2026-08-31 öffnete der Beleg-Knopf in der App direkt die Dateiauswahl.
* Auf dem iPhone landete man damit im Dateispeicher, und ein aus der Mail
* kopiertes PDF war überhaupt nicht einzufügen — im Panel gab es diesen Weg
* längst. Geteilt zwischen dem Anlegen (Tanken.tsx) und dem Nachtragen
* (TankDetail.tsx), damit beide nicht auseinanderlaufen.
*
* Ein Auswahlblatt, kein Popup: es ist eine kurze Liste von Wegen zum selben
* Ziel, und genau dafür sieht Apple das Aktionsblatt vor. „Abbrechen" steht
* abgesetzt in eigener Karte — das macht es zum Ausweg statt zur vierten
* Wahlmöglichkeit.
*
* Welche Wege angeboten werden, entscheidet das Gerät, nicht der Geschmack:
* am Telefon ist Einfügen der Hauptweg, am Rechner Ziehen bzw. Dateiauswahl.
* Begründung in daten/zwischenablage.ts.
*/
import { useRef, useState } from "react"
import { ActionSheet } from "@audi-dash/ui"
import {
ZwischenablageFehler,
pdfAusDateiliste,
pdfAusZwischenablage,
zwischenablageKnopfSinnvoll,
} from "../daten/zwischenablage"
export function BelegPopup({
offen,
beiAbbruch,
beiDatei,
}: {
offen: boolean
beiAbbruch: () => void
/** Bekommt das PDF. Das Blatt schließt sich vorher selbst. */
beiDatei: (datei: File) => void
}) {
const dateiwahl = useRef<HTMLInputElement | null>(null)
const [fehler, setzeFehler] = useState<string | null>(null)
const [darueber, setzeDarueber] = useState(false)
// Einmal beim Aufbau bestimmt: ein Gerätewechsel mitten im Dialog gibt es
// nicht, und ein bei jedem Rendern neu befragtes matchMedia wäre nur Aufwand.
const [telefon] = useState(zwischenablageKnopfSinnvoll)
const uebernehmen = (datei: File) => {
setzeFehler(null)
beiDatei(datei)
}
const einfuegen = async () => {
try {
uebernehmen(await pdfAusZwischenablage())
} catch (f) {
setzeFehler(
f instanceof ZwischenablageFehler
? f.message
: 'Die Zwischenablage ließ sich nicht lesen - bitte "Datei auswählen".',
)
}
}
const aktionen = [
...(telefon
? [{ label: "Aus Zwischenablage einfügen", onClick: () => void einfuegen() }]
: []),
{ label: "Datei auswählen", onClick: () => dateiwahl.current?.click() },
]
// Nur wenn es wirklich etwas zu zeigen gibt - sonst entstuende im Blatt
// eine leere Zeile samt Trennlinie.
const zusatz =
!telefon || fehler ? (
<>
{!telefon && (
<div
className={`dm-beleg-drop${darueber ? " dm-beleg-drop--darueber" : ""}`}
onDragOver={(e) => {
e.preventDefault()
setzeDarueber(true)
}}
onDragLeave={() => setzeDarueber(false)}
onDrop={(e) => {
e.preventDefault()
setzeDarueber(false)
const datei = pdfAusDateiliste(e.dataTransfer?.files)
if (datei) uebernehmen(datei)
else setzeFehler("Das war kein PDF - der Beleg muss als PDF vorliegen.")
}}
>
<strong>PDF hierher ziehen</strong>
<span>oder unten auswählen</span>
</div>
)}
{fehler && (
<p className="dm-fehler" role="alert" style={{ margin: 0 }}>
{fehler}
</p>
)}
</>
) : undefined
return (
<>
{/* Ausserhalb des Blattes: unsichtbar, muss aber im Baum stehen, damit
der Verweis darauf zeigt. */}
<input
ref={dateiwahl}
type="file"
accept="application/pdf,.pdf"
hidden
onChange={(e) => {
const datei = e.target.files?.[0]
e.target.value = ""
if (datei) uebernehmen(datei)
}}
/>
{/* Ohne Ueberschrift und ohne Erklaerzeile: wer den Knopf 'Beleg
hochladen' gedrueckt hat, weiss bereits, was hier passiert - der
Kopf haette es nur wiederholt (Nutzerhinweis 2026-08-31). Die
beiden Wege benennen sich selbst. */}
<ActionSheet open={offen} onClose={beiAbbruch} actions={aktionen}>
{zusatz}
</ActionSheet>
</>
)
}
+9 -4
View File
@@ -23,7 +23,7 @@
import { useEffect, useRef, useState } from "react"
import { ActionButton, Popup } from "@audi-dash/ui"
import { ActionButton, Sheet } from "@audi-dash/ui"
import type { CsvImportErgebnis, DataMetricApi, Fahrt, Profil, Tankvorgang } from "../api"
import { de, eur } from "../format"
@@ -189,8 +189,13 @@ export function DatensatzPopup({
}
return (
<Popup open={offen} onClose={schliessen} variant="form" anchor="center">
<span className="ads-eyebrow">{modus === "sichern" ? "Datensatz sichern" : "Datensatz laden"}</span>
/* Blatt statt schwebender Karte - Apples Vorgabe fuer eine vom Nutzer
angestossene Aufgabe auf dem Telefon. */
<Sheet
open={offen}
onClose={schliessen}
title={modus === "sichern" ? "Datensatz sichern" : "Datensatz laden"}
>
<p className="dm-fussnote">
{modus === "sichern"
? "Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in Tabellenprogrammen aufgehen."
@@ -237,7 +242,7 @@ export function DatensatzPopup({
Fertig
</ActionButton>
</div>
</Popup>
</Sheet>
)
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Ein Datumsfeld, das seinen Wert auch dann anzeigt, wenn er erst später
* eintrifft.
*
* DAS PROBLEM
* -----------
* Alle Datumsfelder der App hängen an Werten aus dem Datenkontext, und der
* füllt sich asynchron: beim ersten Rendern steht dort ein leerer String, den
* echten Wert bekommt das Feld erst danach. WebKit — die Engine der iOS-Hülle —
* baut den inneren Aufbau eines `<input type="date">` beim ersten Rendern auf
* und zieht ihn bei einer späteren Wertänderung von außen nicht zuverlässig
* nach; sichtbar blieb „tt.mm.jjjj", und erst ein Antippen brachte das Datum
* zum Vorschein (gemeldet vom Eigentümer, 2026-08-31: „erst beim ersten Klick
* aufs Datumsfeld erscheinen diese korrekt").
*
* Verschärft wird das durch den `appearance`-Reset in screens.css, der für die
* Box-Optik nötig ist — ohne ihn zeigt iOS sein eigenes, stark abgerundetes
* Chrome („gestreckter Kreis statt Box").
*
* DIE LÖSUNG
* ----------
* Der Schlüssel wechselt genau einmal: von „leer" auf „gefuellt", sobald der
* echte Wert da ist. React baut das Feld an dieser Stelle neu auf, und WebKit
* legt seinen inneren Aufbau damit gleich mit dem Wert an.
*
* Bewusst NICHT der Wert selbst als Schlüssel: dann entstünde das Feld bei
* jeder Auswahl neu, verlöre den Fokus und schlösse auf dem Telefon die
* Datumsauswahl mitten in der Bedienung. Der Wechsel leer→gefüllt passiert
* dagegen genau einmal, und zwar bevor jemand das Feld anfassen kann.
*
* Das Panel braucht das nicht: dort schreibt jedes `render()` das Markup neu,
* das Feld entsteht also ohnehin immer mit seinem Wert.
*/
export function Datumsfeld({
wert,
beiAenderung,
className = "dm-eingabe",
...rest
}: {
wert: string
beiAenderung: (wert: string) => void
className?: string
} & Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "onChange" | "type" | "className"
>) {
return (
<input
key={wert ? "gefuellt" : "leer"}
type="date"
className={className}
value={wert}
onChange={(e) => beiAenderung(e.target.value)}
{...rest}
/>
)
}
+37 -18
View File
@@ -6,11 +6,12 @@
import { useEffect, useRef, useState } from "react"
import { ActionButton, Feld, Popup, Seg, Switch, Tile } from "@audi-dash/ui"
import { ActionButton, Feld, Seg, Sheet, Switch, Tile } from "@audi-dash/ui"
import { DIENST_DOMAIN, zugangLesen, zugangVerwerfen } from "../api"
import type { Profil } from "../api"
import { eigeneVersion } from "../daten/appVersion"
import { Datumsfeld } from "./Datumsfeld"
import { useDaten } from "../daten/DatenKontext"
import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota"
import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter"
@@ -175,9 +176,20 @@ export function Einstellungen({
// Bei Winterraedern zeigt die Seitenansicht das Winterfoto - wie bildInfo()
// im Panel, das hier dieselbe Vorschau liefert.
const gewaehltesBild =
werte.startbild === "seitenansicht.webp" && fahrzeug.reifen.aktiv === "Winter"
/* Verglichen wird der NAME der Ansicht, nicht der Dateiname.
Das Panel speichert in `einstellungen.uebersichtsbild` seit jeher den
Namen ("Seitenansicht"), die App verglich hier gegen den Dateinamen
("seitenansicht.webp") - der Vergleich konnte also nie zutreffen, die
Auswahl fiel immer auf den ersten Eintrag zurueck, und was die App
schrieb, erkannte das Panel seinerseits nicht wieder. Nachgesehen im
echten Profil der Testinstanz (2026-08-31): dort steht
'Seitenansicht'. Kanonisch ist der Name - die aeltere, bereits
gespeicherte Form, dieselbe Entscheidung wie beim Wartungsplan. Die
Namen beider Kataloge stimmen ueberein. */
werte.startbild === "Seitenansicht" && fahrzeug.reifen.aktiv === "Winter"
? { datei: SEITE_WINTER.datei, beschreibung: SEITE_WINTER.beschreibung }
: (BILDPLAETZE.find((b) => b.datei === werte.startbild) ?? BILDPLAETZE[0]!)
: (BILDPLAETZE.find((b) => b.name === werte.startbild) ?? BILDPLAETZE[0]!)
const aendern = (teil: Partial<EinstellungenWerte>) =>
setzeEntwurf({ ...werte, ...teil })
@@ -354,14 +366,23 @@ export function Einstellungen({
<Tile>
<span className="ads-eyebrow">Bild der Übersicht</span>
{/* Sofort gespeichert, nicht ueber die Speicherleiste am Seitenende.
Das Umschalten der Ansicht ist keine Aenderung, die man sammelt
und spaeter bestaetigt - man will das andere Foto sehen. Das
Panel macht es genauso (data-startbild ruft direkt
profilSpeichern()); in der App tauchte stattdessen unten die
Leiste "Aenderungen speichern" auf (gemeldet 2026-08-31). */}
<Feld label="Ansicht" last>
<select
className="dm-auswahl"
value={werte.startbild}
onChange={(e) => aendern({ startbild: e.target.value })}
onChange={(e) => {
const startbild = e.target.value
void profilSpeichern({ einstellungen: { ...werte, startbild } })
}}
>
{BILDPLAETZE.map((platz) => (
<option key={platz.datei} value={platz.datei}>
<option key={platz.datei} value={platz.name}>
{platz.name}
</option>
))}
@@ -391,7 +412,7 @@ export function Einstellungen({
<img
src={markenlogoUrl()}
alt={werte.markenname}
style={{ height: 14, width: "auto" }}
className="dm-markenlogo"
onError={(e) => {
e.currentTarget.style.display = "none"
}}
@@ -442,23 +463,20 @@ export function Einstellungen({
)}
</Tile>
<Popup
{/* Blatt statt schwebender Karte: eine Eingabe, die der Nutzer selbst
angestoßen hat, kommt nach Apples Vorgabe von der unteren Kante -
in Daumenreichweite und mit sichtbarem Weg hinaus. Die alte Karte
saß mittig und war obendrein durchscheinend. */}
<Sheet
open={sdAbfrage}
onClose={() => setzeSdAbfrage(false)}
variant="form"
anchor="center"
title="SmartDeal"
description="Bis wann gilt der Rabatt?"
>
<span className="ads-eyebrow">SmartDeal</span>
<Feld label="Gültig bis" last>
<input
className="dm-eingabe"
type="date"
value={sdEntwurf}
onChange={(e) => setzeSdEntwurf(e.target.value)}
/>
<Datumsfeld wert={sdEntwurf} beiAenderung={setzeSdEntwurf} />
</Feld>
<div className="dm-sd-knoepfe">
<ActionButton onClick={() => setzeSdAbfrage(false)}>Abbrechen</ActionButton>
<ActionButton
variant="primary"
disabled={!sdEntwurf}
@@ -475,8 +493,9 @@ export function Einstellungen({
>
Bestätigen
</ActionButton>
<ActionButton onClick={() => setzeSdAbfrage(false)}>Abbrechen</ActionButton>
</div>
</Popup>
</Sheet>
{/* „Nacht beginnt"/„Nacht endet" sind entfallen: das Panel fuehrt sie
in den Einstellungen nicht, die Nachtgrenze steht dort nur als
+30 -3
View File
@@ -9,7 +9,7 @@
* ist, statt eine glaubwürdig aussehende Erfindung zu zeigen.
*/
import { useState } from "react"
import { useEffect, useState } from "react"
import { ActionButton, Feld, Fig, Pill, Tile } from "@audi-dash/ui"
@@ -30,9 +30,24 @@ function alsDatetimeLocal(iso: string): string {
}
export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (name: SeitenName) => void }) {
const { fahrten, api } = useDaten()
const { fahrten, api, jetztAktualisieren } = useDaten()
const fahrt = fahrten.find((f) => f.trip_id === id)
const [bearbeitenOffen, setzeBearbeitenOffen] = useState(false)
// Vorgriff auf die Antwort des Backends: der Dienstaufruf laeuft ueber die
// Warteschlange und der neue Stand kommt erst mit dem naechsten Push zurueck.
// Ohne diesen Zwischenwert bliebe die Pille nach dem Tippen sekundenlang auf
// dem alten Text stehen und der Tipp saehe aus, als waere er verloren
// gegangen. Das Panel macht dasselbe (dort direkt auf TRIPS, gefolgt von
// render()).
const [artVorgriff, setzeArtVorgriff] = useState<"privat" | "arbeitsweg" | null>(null)
// Faellt der Vorgriff mit dem echten Wert zusammen, wird er ueberfluessig.
// Bleibt er stehen, wuerde eine spaetere Aenderung von aussen (Panel,
// zweites Geraet) von einem laengst erledigten Tipp ueberdeckt.
const echteArt = fahrt?.art
useEffect(() => {
if (artVorgriff && echteArt === artVorgriff) setzeArtVorgriff(null)
}, [echteArt, artVorgriff])
if (!fahrt) {
return (
@@ -43,6 +58,13 @@ export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (n
)
}
const art = artVorgriff ?? fahrt.art
const artUmschalten = () => {
const neu = art === "arbeitsweg" ? "privat" : "arbeitsweg"
setzeArtVorgriff(neu)
void api.fahrtAktualisieren(fahrt.trip_id, { art: neu }).then(() => jetztAktualisieren())
}
const loeschen = () => {
if (!fahrt || !bestaetigen("Fahrt löschen?")) return
void api.fahrtLoeschen(fahrt.trip_id)
@@ -90,7 +112,12 @@ export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (n
<Fig value={deOderStrich(fahrt.distance_km)} unit="km" size={52} />
</div>
<div style={{ marginTop: "15px" }}>
<Pill>{fahrt.art === "arbeitsweg" ? "Arbeitsweg" : "Privat"}</Pill>
{/* Knopf, nicht Anzeige: im Panel schaltet dieselbe Pille seit jeher
zwischen Privat und Arbeitsweg um (data-art, fahrt_aktualisieren).
In der App war sie bis 2026-08-31 ein totes <span>. */}
<Pill onClick={artUmschalten} ariaLabel={`Art der Fahrt umschalten, derzeit ${art === "arbeitsweg" ? "Arbeitsweg" : "Privat"}`}>
{art === "arbeitsweg" ? "Arbeitsweg" : "Privat"}
</Pill>
</div>
</Tile>
+1 -1
View File
@@ -69,7 +69,7 @@ export function Fahrzeugdaten() {
label="Ausführung"
wert={einstellungen.ausfuehrung || "noch offen"}
/>
<Wertzeile label="Fahrgestellnummer" wert={fahrzeug.fin || ""} />
<Wertzeile label="FIN" wert={fahrzeug.fin || ""} />
<Wertzeile label="Motor und Leistung" wert={fahrzeug.details || ""} />
<Wertzeile
label="Erstzulassung"
+10 -4
View File
@@ -17,7 +17,7 @@
import { useEffect, useRef, useState } from "react"
import { ActionButton, Feld, Popup } from "@audi-dash/ui"
import { ActionButton, Feld, Sheet } from "@audi-dash/ui"
import type { DataMetricApi, ImportErgebnis } from "../api"
import { datumZeit, de } from "../format"
@@ -150,8 +150,14 @@ export function HistorienImport({
const fertig = ergebnis !== null && !laeuft
return (
<Popup open={offen} onClose={schliessen} variant="form" anchor="center">
<span className="ads-eyebrow">Daten importieren aus Home Assistant</span>
/* Blatt statt schwebender Karte - Apples Vorgabe fuer eine vom Nutzer
angestossene Eingabe auf dem Telefon. */
<Sheet
open={offen}
onClose={schliessen}
title="Daten importieren"
description="Aus dem Verlauf von Home Assistant."
>
{fertig ? (
<>
@@ -202,6 +208,6 @@ export function HistorienImport({
</div>
</>
)}
</Popup>
</Sheet>
)
}
+82 -6
View File
@@ -26,6 +26,81 @@ export interface Punkt {
const KACHEL_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
const KACHEL_ZUSATZ = "© OpenStreetMap contributors"
const ROT = "#F50537"
/* Fahrtrichtung auf der Streckenlinie. Deckungsgleich mit spurPfeile() im
Panel - dort steht die ausfuehrliche Begruendung.
Kurz: bis 2026-08-31 lag auf der Linie je ein runder Punkt pro
GPS-Stuetzpunkt. Die sagten nichts ueber die Richtung. Die Form ist der
Navigationspfeil und nicht ein gleichschenkliges Dreieck: das Dreieck
sieht von beiden Seiten gleich aus, die Kerbe im Fuss macht Kopf und Ende
unterscheidbar. Zwei Entscheidungen,
an Mustern nachgemessen: Abstand nach Strecke statt nach Stuetzpunkten (die
Punktdichte haengt an der Geschwindigkeit, Pfeile daran wuerden dort klumpen,
wo am wenigsten passiert ist), und weisser Rand (sonst verschwimmt das rote
Dreieck mit der roten Linie). Ein Versatz quer zur Fahrtrichtung war erprobt
und trennt Hin- und Rueckweg - aber nur bei Punkt fuer Punkt deckungsgleichen
Spuren, die es in echten Aufzeichnungen nicht gibt. */
function spurMeter(a: [number, number], b: [number, number]): number {
const R = 6371000,
r = Math.PI / 180
const dLat = (b[0] - a[0]) * r,
dLon = (b[1] - a[1]) * r
const h = Math.sin(dLat / 2) ** 2 + Math.cos(a[0] * r) * Math.cos(b[0] * r) * Math.sin(dLon / 2) ** 2
return R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h))
}
/** Winkel im Uhrzeigersinn ab Norden - genau das, was CSS rotate() erwartet.
Die Laengendifferenz wird mit cos(Breite) gestaucht, sonst zeigten die
Pfeile in unseren Breiten rund 30 Grad daneben. */
function spurWinkel(a: [number, number], b: [number, number]): number {
const dx = (b[1] - a[1]) * Math.cos((((a[0] + b[0]) / 2) * Math.PI) / 180)
return (Math.atan2(dx, b[0] - a[0]) * 180) / Math.PI
}
function spurPfeile(
L: typeof import("leaflet"),
karte: import("leaflet").Map,
spur: [number, number][],
): void {
if (spur.length < 2) return
const laengen: number[] = []
let gesamt = 0
for (let i = 1; i < spur.length; i++) {
const d = spurMeter(spur[i - 1]!, spur[i]!)
laengen.push(d)
gesamt += d
}
if (!gesamt) return
// Rund alle 350 m, aber nie weniger als 4 und nie mehr als 14: auf einer
// 200-km-Fahrt waeren 570 Pfeile eine zweite Linie, auf 300 m gar keiner.
const anzahl = Math.max(4, Math.min(14, Math.round(gesamt / 350)))
const schritt = gesamt / (anzahl + 1)
let ziel = schritt,
gelaufen = 0
for (let i = 1; i < spur.length && ziel < gesamt; i++) {
const d = laengen[i - 1]!
while (ziel <= gelaufen + d && ziel < gesamt) {
const f = d ? (ziel - gelaufen) / d : 0
const lat = spur[i - 1]![0] + (spur[i]![0] - spur[i - 1]![0]) * f
const lon = spur[i - 1]![1] + (spur[i]![1] - spur[i - 1]![1]) * f
const w = spurWinkel(spur[i - 1]!, spur[i]!).toFixed(1)
const html =
`<div style="transform:rotate(${w}deg)">` +
'<svg width="13" height="13" viewBox="0 0 10 10" style="overflow:visible">' +
'<path d="M5 0.4 L8.8 9.6 L5 6.4 L1.2 9.6 Z" fill="none" stroke="#fff" stroke-width="1.8" stroke-linejoin="round" opacity=".95"/>' +
`<path d="M5 0.4 L8.8 9.6 L5 6.4 L1.2 9.6 Z" fill="${ROT}"/></svg></div>`
L.marker([lat, lon], {
icon: L.divIcon({ className: "dm-spur-pfeil", html, iconSize: [13, 13], iconAnchor: [6.5, 6.5] }),
interactive: false,
}).addTo(karte)
ziel += schritt
}
gelaufen += d
}
}
export function Karte({
start,
ziel,
@@ -61,18 +136,19 @@ export function Karte({
if (ziel) {
punkte.push([ziel.lat, ziel.lon])
L.circleMarker([ziel.lat, ziel.lon], { radius: 6, color: "#F50537", weight: 2, fillColor: "#F50537", fillOpacity: 1 })
L.circleMarker([ziel.lat, ziel.lon], { radius: 6, color: ROT, weight: 2, fillColor: ROT, fillOpacity: 1 })
.bindTooltip("Ziel")
.addTo(karte)
// Echte Route, wenn vorhanden - sonst eine Verbindungslinie NUR zwischen
// den beiden echten Punkten, kein erfundener Verlauf dazwischen.
const linie = route && route.length >= 2 ? route : punkte
L.polyline(linie, { color: "#F50537", weight: 2.5, opacity: 0.9, dashArray: route && route.length >= 2 ? undefined : "4 6" }).addTo(karte)
L.polyline(linie, { color: ROT, weight: 2.5, opacity: 0.9, dashArray: route && route.length >= 2 ? undefined : "4 6" }).addTo(karte)
if (route && route.length >= 2) {
// Kleine Zwischenpunkte entlang der echten Strecke, wie im Panel.
route.slice(1, -1).forEach((punkt) => {
L.circleMarker(punkt, { radius: 2.5, color: "#F50537", weight: 0, fillColor: "#F50537", fillOpacity: 0.85 }).addTo(karte!)
})
// Richtungspfeile statt der frueheren Punkte, wie im Panel. Nur auf
// einer echten Aufzeichnung: die gestrichelte Ersatzlinie zwischen
// Start und Ziel ist keine gefahrene Strecke, und ein Pfeil darauf
// behauptete eine Fahrtrichtung, die niemand gemessen hat.
spurPfeile(L, karte, route)
punkte.push(...route)
}
}
+6 -12
View File
@@ -17,6 +17,7 @@ import type { Reifensatz, ReifenArchivEintrag } from "../daten/profilAdapter"
import { datum, de, deOderStrich } from "../format"
import { SymbolEdit, SymbolPaket, SymbolZahnradVoll } from "../symbole"
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Datumsfeld } from "./Datumsfeld"
import { Bild } from "./Bild"
import { kalenderDateiLaden } from "./kalender"
@@ -60,7 +61,7 @@ export function Reifen() {
},
})
const ziel = aktiv === "Sommer" ? "Winter" : "Sommer"
kalenderDateiLaden(
await kalenderDateiLaden(
"Reifenwechsel",
wechselDatumAnzeige,
`Wechsel auf ${ziel}reifen`,
@@ -301,12 +302,7 @@ export function Reifen() {
<Tile>
<span className="ads-eyebrow">Wechseltermin</span>
<Feld label="Geplant am">
<input
className="dm-eingabe"
type="date"
value={wechselDatumAnzeige}
onChange={(e) => setzeWechselDatum(e.target.value)}
/>
<Datumsfeld wert={wechselDatumAnzeige} beiAenderung={setzeWechselDatum} />
</Feld>
<Feld label="Termin vereinbart" last>
<Switch
@@ -401,11 +397,9 @@ function ArchivZeile({ eintrag }: { eintrag: ReifenArchivEintrag }) {
{bearbeiten ? (
<>
<Feld label="Ersetzt am">
<input
className="dm-eingabe"
type="date"
value={form.ersetzt_am}
onChange={(e) => setzeForm({ ...form, ersetzt_am: e.target.value })}
<Datumsfeld
wert={form.ersetzt_am}
beiAenderung={(w) => setzeForm({ ...form, ersetzt_am: w })}
/>
</Feld>
<Feld label="Gefahrene Strecke" unit="km">
+4 -13
View File
@@ -30,6 +30,7 @@ import {
Zahnradknopf,
bestaetigen,
} from "./bausteine"
import { Datumsfeld } from "./Datumsfeld"
import { kalenderDateiLaden } from "./kalender"
import { vcardParsen } from "./vcard"
@@ -295,15 +296,10 @@ function TerminVereinbaren() {
</select>
</Feld>
<Feld label="Werkstatttermin am" last>
<input
className="dm-eingabe"
type="date"
value={datumWert}
onChange={(e) => void merken(e.target.value)}
/>
<Datumsfeld wert={datumWert} beiAenderung={(w) => void merken(w)} />
</Feld>
<ActionButton
onClick={() => kalenderDateiLaden(art, datumWert || isoTag(), `${art} in der Werkstatt`)}
onClick={() => void kalenderDateiLaden(art, datumWert || isoTag(), `${art} in der Werkstatt`)}
>
In den Kalender übernehmen
</ActionButton>
@@ -480,12 +476,7 @@ export function Servicebuch({
<Tile>
<span className="ads-eyebrow">Eintrag</span>
<Feld label="Datum">
<input
className="dm-eingabe"
type="date"
value={entwurf.datum ?? ""}
onChange={(e) => setze("datum", e.target.value)}
/>
<Datumsfeld wert={entwurf.datum ?? ""} beiAenderung={(w) => setze("datum", w)} />
</Feld>
<Feld label="Kilometerstand" unit="km">
<input
+33 -7
View File
@@ -141,15 +141,32 @@ interface EigenePosition {
lon: number
}
function eigenePositionErfassen(
/**
* Fortlaufende Ortung dieses Geraets, solange die Standortseite offen ist.
*
* Bis 2026-08-31 stand hier getCurrentPosition - eine Momentaufnahme beim
* Oeffnen der Seite. Der eigene Punkt blieb danach stehen, auch wenn man
* weiterging, und der angezeigte Abstand zum Fahrzeug veraltete still.
*
* Die Rueckgabe ist die Abmeldung: der Aufrufer haengt sie an einen
* useEffect, damit die Ortung endet, sobald die Seite verlassen wird. Genau
* das meint Apples "Beim Verwenden der App" - erfassen, solange jemand die
* Karte ansieht, und keine Sekunde laenger.
*
* iOS zeigt die noetige Rueckfrage nur, wenn die native Huelle
* NSLocationWhenInUseUsageDescription im Info.plist fuehrt; eingetragen wird
* der Schluessel in scripts/ios-signieren.sh. Ohne ihn schlaegt schon der
* erste Aufruf fehl, unabhaengig von diesem Code.
*/
function standortVerfolgen(
setzePosition: (p: EigenePosition | null) => void,
setzeFehler: (f: string | null) => void,
): void {
): () => void {
if (!navigator.geolocation) {
setzeFehler("GPS offline")
return
return () => {}
}
navigator.geolocation.getCurrentPosition(
const kennung = navigator.geolocation.watchPosition(
(pos) => {
setzeFehler(null)
setzePosition({ lat: pos.coords.latitude, lon: pos.coords.longitude })
@@ -160,8 +177,11 @@ function eigenePositionErfassen(
// sonst "GPS offline" (deckungsgleich mit dem Panel).
setzeFehler(err.code === err.PERMISSION_DENIED ? "GPS-Freigabe fehlt" : "GPS offline")
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 },
// maximumAge klein: eine zwischengespeicherte Position aus der letzten
// halben Minute waere hier das Gegenteil von "live".
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 2000 },
)
return () => navigator.geolocation.clearWatch(kennung)
}
/** Kleine, nicht interaktive Kartenvorschau auf der Übersicht — Vorlage:
@@ -227,7 +247,14 @@ export function Standort() {
const [satellit, setzeSatellit] = useState(false)
const [eigenePosition, setzeEigenePosition] = useState<EigenePosition | null>(null)
const [positionsFehler, setzePositionsFehler] = useState<string | null>(null)
// Zaehler statt Schalter: jeder Druck auf den GPS-Knopf ohne bekannte
// Position soll die Ortung neu anstossen, nicht nur der erste. Ein einmal
// gescheiterter watchPosition-Auftrag meldet sich nicht von selbst wieder -
// etwa nachdem die Freigabe in den iOS-Einstellungen nachgereicht wurde.
const [ortungsLauf, setzeOrtungsLauf] = useState(0)
const [adresse, setzeAdresse] = useState<string | null>(null)
useEffect(() => standortVerfolgen(setzeEigenePosition, setzePositionsFehler), [ortungsLauf])
const [blattOffen, setzeBlattOffen] = useState(false)
const blattRef = useRef<HTMLDivElement | null>(null)
// Zug am Griff, deckungsgleich mit standortMenuVerdrahten() im Panel: das
@@ -299,7 +326,6 @@ export function Standort() {
if (fahrzeugPos) karte.setView([fahrzeugPos.lat, fahrzeugPos.lon], 15)
else karte.setView([51.16, 10.45], 5)
setTimeout(() => karte.invalidateSize(), 60)
eigenePositionErfassen(setzeEigenePosition, setzePositionsFehler)
})
return () => {
abgebrochen = true
@@ -457,7 +483,7 @@ export function Standort() {
type="button"
onClick={() => {
if (!eigenePosition) {
eigenePositionErfassen(setzeEigenePosition, setzePositionsFehler)
setzeOrtungsLauf((l) => l + 1)
return
}
kartenRef.current?.flyTo([eigenePosition.lat, eigenePosition.lon], Math.max(kartenRef.current.getZoom(), 15))
+12 -12
View File
@@ -7,7 +7,7 @@
* Genau dieses Muster hatte auch das alte Panel.
*/
import { useEffect, useRef, useState } from "react"
import { useEffect, useState } from "react"
import { ActionButton, Feld, Fig, Tile } from "@audi-dash/ui"
@@ -18,6 +18,7 @@ import { datum, de, deOderStrich, eur, uhrzeit } from "../format"
import type { SeitenName } from "../navigation"
import { KRAFTSTOFFSORTEN, Leerzustand, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { markenlogoUrl } from "./bilder"
import { BelegPopup } from "./BelegPopup"
import { TankstellenKarte } from "./TankstellenKarte"
interface BelegErgebnis {
@@ -316,7 +317,9 @@ function BelegKnopf({
belegDatei: string | null
api: ReturnType<typeof useDaten>["api"]
}) {
const dateiwahl = useRef<HTMLInputElement | null>(null)
// Wie in Tanken.tsx: erst das Beleg-Fenster, dann der gewaehlte Weg. Am
// Telefon ist das Einfuegen aus der Zwischenablage der Hauptweg.
const [belegFensterOffen, setzeBelegFensterOffen] = useState(false)
const [laeuft, setzeLaeuft] = useState(false)
const [meldung, setzeMeldung] = useState<{ art: "ok" | "fehler"; text: string } | null>(null)
@@ -384,15 +387,12 @@ function BelegKnopf({
return (
<>
<input
ref={dateiwahl}
type="file"
accept="application/pdf,.pdf"
hidden
onChange={(e) => {
const datei = e.target.files?.[0]
if (datei) void hochladen(datei)
e.target.value = ""
<BelegPopup
offen={belegFensterOffen}
beiAbbruch={() => setzeBelegFensterOffen(false)}
beiDatei={(datei) => {
setzeBelegFensterOffen(false)
void hochladen(datei)
}}
/>
{meldung && (
@@ -403,7 +403,7 @@ function BelegKnopf({
{meldung.text}
</p>
)}
<ActionButton onClick={() => dateiwahl.current?.click()} disabled={laeuft || wartet}>
<ActionButton onClick={() => setzeBelegFensterOffen(true)} disabled={laeuft || wartet}>
{laeuft
? "Lade hoch …"
: wartet
+45 -13
View File
@@ -6,7 +6,7 @@
* heben wie eine große.
*/
import { useRef, useState } from "react"
import { useEffect, useState, useSyncExternalStore } from "react"
import { Accordion, ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
@@ -15,7 +15,13 @@ import { useDaten } from "../daten/DatenKontext"
import { nachJahrUndMonat } from "../daten/statistik"
import { datum, de, eur, schnittpreis, summe } from "../format"
import type { SeitenName } from "../navigation"
import {
geteiltenBelegHorchen,
geteiltenBelegSetzen,
geteiltenBelegStand,
} from "../daten/geteilterBeleg"
import { Blattzeile, KRAFTSTOFFSORTEN, Leerzustand } from "./bausteine"
import { BelegPopup } from "./BelegPopup"
import { markenlogoUrl } from "./bilder"
import { Zeilenmenue } from "./Zeilenmenue"
@@ -23,6 +29,15 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
const { tankvorgaenge, fahrzeug, einstellungen, api } = useDaten()
const [formularOffen, setzeFormularOffen] = useState(false)
/* Kommt ein Beleg ueber das Teilen-Blatt herein (App.tsx holt ihn ab und
springt hierher), oeffnet sich das Formular von selbst - das Formular
selbst nimmt den Beleg dann auf. Ohne das laege der Beleg bereit und
niemand saehe, dass etwas passiert ist. */
const geteilt = useSyncExternalStore(geteiltenBelegHorchen, geteiltenBelegStand)
useEffect(() => {
if (geteilt) setzeFormularOffen(true)
}, [geteilt])
const gruppen = nachJahrUndMonat(tankvorgaenge, (t) => t.ts)
const smartdealAktiv = fahrzeug?.smartdeal.aktiv === true
@@ -59,7 +74,8 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
<img
src={markenlogoUrl()}
alt={einstellungen?.markenname ?? "Logo"}
style={{ height: 14, verticalAlign: "middle", marginRight: 6 }}
className="dm-markenlogo"
style={{ marginRight: 6 }}
onError={(e) => {
e.currentTarget.style.display = "none"
}}
@@ -172,7 +188,26 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
einem bereits bestehenden Tankvorgang nachtragen. */
const [beleg, setzeBeleg] = useState<BelegEntwurf | null>(null)
const [belegLaeuft, setzeBelegLaeuft] = useState(false)
const belegwahl = useRef<HTMLInputElement | null>(null)
// Der Knopf oeffnet nicht mehr direkt die Dateiauswahl, sondern das
// Beleg-Fenster: auf dem iPhone ist Einfuegen aus der Zwischenablage der
// Hauptweg (PDF aus der Mail), die Dateiauswahl landet dort im
// Dateispeicher. Genau so macht es das Panel seit laengerem.
const [belegFensterOffen, setzeBelegFensterOffen] = useState(false)
/* Ein ueber das Teilen-Blatt hereingereichter Beleg laeuft durch genau
denselben belegLesen()-Weg wie eine von Hand gewaehlte Datei - er wird
also gelesen, ausgewertet und fuellt das Formular vor. Danach aus dem
geteilten Speicher nehmen, sonst laeuft er beim naechsten Oeffnen des
Formulars ein zweites Mal durch. */
const geteilt = useSyncExternalStore(geteiltenBelegHorchen, geteiltenBelegStand)
useEffect(() => {
if (!geteilt) return
geteiltenBelegSetzen(null)
void belegLesen(geteilt)
// belegLesen haengt an api/fahrzeug und ist bei jedem Rendern neu;
// ausschlaggebend ist allein, dass ein Beleg hereingekommen ist.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [geteilt])
const smartdealAktiv = fahrzeug?.smartdeal.aktiv === true
@@ -348,18 +383,15 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
Felder und die Knöpfe. */}
{/* Drei Knöpfe wie in vFuelFormular(): erst den Beleg lesen lassen, dann
mit oder ohne ihn speichern. */}
<input
ref={belegwahl}
type="file"
accept="application/pdf,.pdf"
hidden
onChange={(e) => {
const datei = e.target.files?.[0]
if (datei) void belegLesen(datei)
e.target.value = ""
<BelegPopup
offen={belegFensterOffen}
beiAbbruch={() => setzeBelegFensterOffen(false)}
beiDatei={(datei) => {
setzeBelegFensterOffen(false)
void belegLesen(datei)
}}
/>
<ActionButton onClick={() => belegwahl.current?.click()} disabled={belegLaeuft || laeuft}>
<ActionButton onClick={() => setzeBelegFensterOffen(true)} disabled={belegLaeuft || laeuft}>
{belegLaeuft ? "Beleg wird gelesen …" : beleg ? "Beleg ersetzen" : "Beleg hochladen"}
</ActionButton>
<ActionButton variant="primary" onClick={() => void speichern(true)} disabled={laeuft}>
+5 -1
View File
@@ -42,7 +42,11 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
// Bodenschatten unter dem Fahrzeug, wie szeneKlasse() im Panel: abschaltbar,
// nicht bei Innenraumaufnahmen, blaustichig bei montierten Winterrädern.
const winterraeder = fahrzeug.reifen.aktiv === "Winter"
const platz = BILDPLAETZE.find((p) => p.datei === einstellungen.startbild) ?? BILDPLAETZE[0]!
/* Der gespeicherte Wert ist der NAME der Ansicht, nicht der Dateiname -
siehe die Begruendung in Einstellungen.tsx. Der Vergleich gegen datei
traf nie zu, es wurde also immer der erste Platz gezeigt, egal was in
den Einstellungen gewaehlt war. */
const platz = BILDPLAETZE.find((p) => p.name === einstellungen.startbild) ?? BILDPLAETZE[0]!
// Bei Winterrädern zeigt die Seitenansicht das Winterfoto, wie bildInfo()
// im Panel — das fehlte hier ganz.
const startbild = bildInfo(platz, winterraeder)
+5 -11
View File
@@ -15,6 +15,7 @@ import { useDaten } from "../daten/DatenKontext"
import { datum, de, eur } from "../format"
import type { SeitenName } from "../navigation"
import { Leerzustand, Wertzeile, Werteliste, Zahnradknopf } from "./bausteine"
import { Datumsfeld } from "./Datumsfeld"
interface Beitragsposten {
bez?: string
@@ -209,12 +210,7 @@ export function Beitrag() {
<Tile>
<span className="ads-eyebrow">Beitrag anpassen</span>
<Feld label="Gültig ab">
<input
className="dm-eingabe"
type="date"
value={gueltigAb}
onChange={(e) => setzeGueltigAb(e.target.value)}
/>
<Datumsfeld wert={gueltigAb} beiAenderung={setzeGueltigAb} />
</Feld>
<Feld label="Schadenfreiheitsklasse Haftpflicht">
<input
@@ -688,11 +684,9 @@ export function Steuer() {
</select>
</Feld>
<Feld label="Nächste Fälligkeit">
<input
className="dm-eingabe"
type="date"
value={(entwurf["faellig"] as string) ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, faellig: e.target.value }))}
<Datumsfeld
wert={(entwurf["faellig"] as string) ?? ""}
beiAenderung={(w) => setzeEntwurf((alt) => ({ ...alt, faellig: w }))}
/>
</Feld>
<Feld label="Lastschrift erteilt" last>
+39 -25
View File
@@ -5,12 +5,13 @@
import { useRef, useState, type ReactNode } from "react"
import { ActionButton, Tile } from "@audi-dash/ui"
import { ActionButton, ActionSheet, Tile } from "@audi-dash/ui"
import type { SicherheitsPunkt } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { SymbolZahnradVoll } from "../symbole"
import { Bild } from "./Bild"
import { bildVersionErneuern } from "./bilder"
/** Kraftstoffsorten zur Auswahl - dieselbe feste Liste wie `KRAFTSTOFFSORTEN`
im Panel, geteilt zwischen Tanken.tsx (Neuanlegen) und TankDetail.tsx
@@ -83,18 +84,17 @@ export function BildMitMenue({
beschreibung,
name,
groesse,
menueLage,
dateiZeigen,
}: {
datei: string
beschreibung: string
name: string
groesse?: "default" | "mini" | "round"
/** "ansicht" = unter dem Bild zentriert, "rad" = links oben (wie im Panel). */
menueLage?: "ansicht" | "rad"
/* menueLage ist am 2026-08-31 entfallen: ein Aktionsblatt kommt immer von
der unteren Kante, es gibt keine Lage mehr zu waehlen. */
dateiZeigen?: boolean
}) {
const { api } = useDaten()
const { api, jetztAktualisieren } = useDaten()
const [offen, setzeOffen] = useState(false)
const datei_ = useRef<HTMLInputElement | null>(null)
@@ -106,7 +106,13 @@ export function BildMitMenue({
leser.readAsDataURL(gewaehlt)
})
await api.bildHochladen(datei, base64)
/* Ohne diese beiden Zeilen bliebe das alte Foto stehen: die Adresse ist
unveraendert, und Home Assistant liefert /local/ mit 31 Tagen
Cache-Vorgabe aus. Erneuern setzt den Bildstand, das Nachladen bringt
die Oberflaeche dazu, ihn auch zu benutzen. */
bildVersionErneuern()
setzeOffen(false)
void jetztAktualisieren()
}
return (
@@ -131,26 +137,34 @@ export function BildMitMenue({
e.target.value = ""
}}
/>
{offen && (
<>
<div className="dm-bildmenu-fang" onClick={() => setzeOffen(false)} />
<div className={menueLage === "rad" ? "dm-bildmenu dm-bildmenu--rad" : "dm-bildmenu"}>
<button type="button" onClick={() => datei_.current?.click()}>
Foto ersetzen
</button>
<button
type="button"
className="dm-bildmenu__loeschen"
onClick={() => {
setzeOffen(false)
if (bestaetigen("Foto löschen?")) void api.bildLoeschen(datei)
}}
>
Löschen
</button>
</div>
</>
)}
{/* Auswahlblatt statt schwebendem Menü: zwei Wege, die der Nutzer
selbst angestoßen hat, plus ein abgesetztes Abbrechen - genau der
Zuschnitt, für den Apple das Aktionsblatt vorsieht. Das frühere
Menü schwebte neben dem Bild, hatte keinen erkennbaren Ausweg und
saß je nach Lage woanders. */}
<ActionSheet
open={offen}
onClose={() => setzeOffen(false)}
title={name}
actions={[
{
label: "Foto ersetzen",
onClick: () => datei_.current?.click(),
},
{
label: "Foto löschen",
destructive: true,
onClick: () => {
setzeOffen(false)
if (!bestaetigen("Foto löschen?")) return
void api.bildLoeschen(datei).then(() => {
bildVersionErneuern()
return jetztAktualisieren()
})
},
},
]}
/>
</div>
)
}
+27 -1
View File
@@ -63,6 +63,32 @@ export const ERLAUBTE_DATEINAMEN: readonly string[] = [
...WEITERE_BILDER,
]
/**
* Stand der Fahrzeugfotos, als Anhängsel an jede Bildadresse.
*
* Home Assistant liefert alles unter `/local/` mit
* `Cache-Control: public, max-age=2678400` aus — einunddreißig Tage
* (nachgemessen 2026-08-31). Ohne ein wechselndes Anhängsel zeigt die App
* deshalb einen Monat lang das Bild, das sie beim ersten Mal geholt hat:
* ein neu hochgeladenes Foto erscheint nicht, ein ersetztes bleibt das alte,
* und ein 404 von vorher bleibt ein 404. Genau das ist dem Eigentümer mit
* den Radfotos passiert — im Panel eingefügt, in der App unsichtbar.
*
* Das Panel hängt seit jeher `?v=${bildVersion}` an (`bildInfo()` dort) und
* setzt den Wert nach jedem Hochladen oder Löschen neu. Hier dasselbe: beim
* Start der App einmal, danach bei jeder Änderung.
*
* Der Preis ist ein erneuter Abruf je App-Start. Der ist gewollt — ein Foto,
* das der Nutzer nicht sieht, kostet mehr als ein Megabyte über eine
* Verbindung, die ohnehin gerade das ganze Fahrzeugprofil holt.
*/
let bildVersion = Date.now()
/** Nach dem Hochladen oder Löschen eines Fotos aufrufen. */
export function bildVersionErneuern(): void {
bildVersion = Date.now()
}
/**
* Adresse eines Fahrzeugfotos auf dem Server. Die Bilder liegen unter
* `/local/bilder/` — anders als beim alten Panel läuft die App nicht
@@ -72,7 +98,7 @@ export function bildUrl(dateiname: string | null | undefined): string | undefine
if (!dateiname) return undefined
const basis = zugangLesenSynchron()?.basisUrl
if (!basis) return undefined
return `${basis}/local/bilder/${dateiname}`
return `${basis}/local/bilder/${dateiname}?v=${bildVersion}`
}
/**
@@ -0,0 +1,69 @@
/**
* Regression: die Fotogalerie unter „Mein Audi" behielt den Platzhalter.
*
* `ImagePlaceholder` merkte sich nur, DASS ein Bild nicht geladen werden
* konnte — nicht welches. In der Galerie bleibt dieselbe Komponente stehen und
* bekommt beim Weiterschalten nur eine neue `src`; ein einmal gesetztes
* „fehlgeschlagen" galt damit für alle folgenden Bilder mit. Sobald ein noch
* leerer Platz durchlaufen war, zeigten auch die Plätze mit echtem Foto nur
* noch den Platzhalter, bis ein Seitenwechsel die Komponente neu aufbaute
* (gemeldet vom Eigentümer, 2026-08-31).
*
* Das Panel hat diese Falle nie gehabt: `bildWeiter()` nimmt dort die Klasse
* `bild-fehlt` ausdrücklich wieder weg, mit genau diesem Kommentar.
*
* Warum der Test hier und nicht im Design-System liegt: dort gibt es keine
* Testumgebung mit DOM. Geprüft wird die Bibliothek über die App, die sie
* benutzt — dieselbe Stelle, an der der Fehler aufgefallen ist.
*/
import { fireEvent, render } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { ImagePlaceholder } from "@audi-dash/ui"
const bild = (behaelter: HTMLElement) => behaelter.querySelector("img")
const platzhalter = (behaelter: HTMLElement) => behaelter.querySelector(".ads-platzhalter")
describe("ImagePlaceholder", () => {
it("zeigt den Platzhalter, wenn das Bild nicht lädt", () => {
const { container } = render(<ImagePlaceholder src="/a.webp" alt="A" label="A" />)
expect(bild(container)).not.toBeNull()
fireEvent.error(bild(container)!)
expect(bild(container)).toBeNull()
expect(platzhalter(container)).not.toBeNull()
})
it("zeigt das nächste Bild wieder an, nachdem das vorherige fehlgeschlagen ist", () => {
const { container, rerender } = render(<ImagePlaceholder src="/leer.webp" alt="A" label="A" />)
fireEvent.error(bild(container)!)
expect(platzhalter(container)).not.toBeNull()
// Weiterschalten in der Galerie: dieselbe Komponente, neue Adresse.
rerender(<ImagePlaceholder src="/foto.webp" alt="B" label="B" />)
expect(bild(container)).not.toBeNull()
expect(bild(container)!.getAttribute("src")).toBe("/foto.webp")
expect(platzhalter(container)).toBeNull()
})
it("merkt sich den Fehlschlag pro Adresse, nicht global", () => {
const { container, rerender } = render(<ImagePlaceholder src="/leer.webp" alt="A" label="A" />)
fireEvent.error(bild(container)!)
rerender(<ImagePlaceholder src="/foto.webp" alt="B" label="B" />)
expect(bild(container)).not.toBeNull()
// Zurück auf den bekannt leeren Platz: dort gehört der Platzhalter hin.
rerender(<ImagePlaceholder src="/leer.webp" alt="A" label="A" />)
expect(platzhalter(container)).not.toBeNull()
})
it("zeigt den Platzhalter ohne Adresse", () => {
const { container } = render(<ImagePlaceholder alt="A" label="Auto Seite" />)
expect(bild(container)).toBeNull()
expect(platzhalter(container)).not.toBeNull()
})
})
+44 -4
View File
@@ -1,10 +1,26 @@
/**
* Termine als Kalenderdatei. Portiert aus `ics()` im alten Panel.
*
* Erzeugt eine .ics-Datei im Browser, ohne Server — sie landet als Download
* bzw. auf dem iPhone direkt im Kalender. Mit Erinnerung zwei Tage vorher.
* Erzeugt eine .ics-Datei ohne Server, mit Erinnerung zwei Tage vorher.
*
* ZWEI WEGE, WEIL EIN DOWNLOAD IN DER APP NICHT ANKOMMT
* -----------------------------------------------------
* Im Browser und im HA-Panel genügt ein `<a download>` mit einer
* Blob-Adresse. In der nativen Hülle nicht: **WKWebView ignoriert das
* `download`-Attribut**, der Klick läuft ins Leere und es passiert sichtbar
* nichts — genau so gemeldet („Wechseltermin in Kalender übernehmen klappt
* nicht", 2026-08-31).
*
* Auf dem Gerät geht die Datei deshalb ins temporäre Verzeichnis und von
* dort ins iOS-Teilen-Blatt, aus dem „Zu Kalender hinzufügen" kommt. Das ist
* auch der ehrlichere Ablauf: man sieht, wohin der Termin geht, statt dass
* eine Datei stillschweigend irgendwo landet.
*/
import { Capacitor } from "@capacitor/core"
import { Directory, Encoding, Filesystem } from "@capacitor/filesystem"
import { Share } from "@capacitor/share"
function zeilenUmbruch(text: string): string {
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<void> {
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()
+12
View File
@@ -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;
+76 -13
View File
@@ -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;
}
+5 -5
View File
@@ -70,16 +70,16 @@ export function SymbolAudi({ groesse }: SymbolProps) {
)
}
/* CI-Symbol tour-s statt der frueheren handgezeichneten Route. Eine
Fuellflaeche, keine Kontur - deshalb voll, wie bei SymbolAudi und
SymbolStatistik. */
export function SymbolFahrten({ groesse }: SymbolProps) {
return (
<Rahmen
voll
groesse={groesse}
kinder={
<g>
<circle cx="6" cy="6" r="2.2" />
<circle cx="18" cy="18" r="2.2" />
<path d="M8 7.5c4 1.5 4.5 3 3 5s-1 3.5 4.5 5" />
</g>
<path d="M3.4 3C2.6 3 2 3.64 2 4.5c0 .16.08.46.27.89a15.5 15.5 0 0 0 1.23 2.2 18.4 18.4 0 0 0 1.23-2.2c.19-.43.27-.73.27-.89C5 3.68 4.32 3 3.5 3zm.1 5.5-.41.28-.03-.03a18 18 0 0 1-.3-.45l-.7-1.14a14 14 0 0 1-.7-1.37A3.5 3.5 0 0 1 1 4.5C1 3.16 2 2 3.4 2h.1A2.5 2.5 0 0 1 6 4.5c0 .39-.17.86-.35 1.29a27 27 0 0 1-1.71 2.96l-.02.02v.01zm0 0 .41.28-.41.6-.41-.6zM12.4 3c-.8 0-1.4.64-1.4 1.5 0 .16.08.46.27.89a15.5 15.5 0 0 0 1.23 2.2 18.4 18.4 0 0 0 1.23-2.2c.19-.43.27-.73.27-.89 0-.82-.68-1.5-1.5-1.5zm.1 5.5-.41.28-.03-.03a12 12 0 0 1-.3-.45l-.7-1.14c-.25-.44-.5-.92-.7-1.37A3.5 3.5 0 0 1 10 4.5C10 3.16 11 2 12.4 2h.1A2.5 2.5 0 0 1 15 4.5c0 .39-.17.86-.35 1.29a27 27 0 0 1-1.71 2.96l-.02.02v.01zm0 0 .41.28-.41.6-.41-.6zm5.79-.5h.21a4.5 4.5 0 0 1 4.5 4.5c0 .66-.3 1.52-.67 2.38-.38.88-.9 1.83-1.4 2.7a54 54 0 0 1-1.97 3.14l-.04.05v.01l-.42-.28-.41.28-.01-.01-.04-.05a40 40 0 0 1-.58-.89 33 33 0 0 1-2.8-4.95A6.6 6.6 0 0 1 14 12.5 4.34 4.34 0 0 1 18.28 8Zm.21 12.5-.41.28.41.6.41-.6zm0-.9a53 53 0 0 0 1.57-2.53c.5-.85.98-1.76 1.35-2.6s.58-1.53.58-1.97c0-1.92-1.58-3.5-3.5-3.5h-.19c-1.94.1-3.4 1.58-3.31 3.48v.02c0 .44.2 1.13.58 1.98.37.83.86 1.74 1.35 2.6a53 53 0 0 0 1.57 2.52M9.94 11H3v-1h10.06l-7.61 5.44 13.29 7.12-.48.88-14.7-7.88L9.93 11Z" />
}
/>
)
+25 -8
View File
@@ -1,23 +1,40 @@
import { beforeEach, describe, expect, it } from "vitest"
/**
* Der Startwert des Themas wird beim Laden des Moduls **einmal** aus der
* Ablage gelesen und danach im Speicher gehalten — das verlangt
* `useSyncExternalStore`, dessen Momentaufnahme zwischen zwei Änderungen
* stabil sein muss (siehe theme.ts).
*
* Deshalb muss jeder Fall das Modul frisch laden, statt die Ablage zu
* beschreiben und dieselbe Instanz erneut zu fragen. Das ist keine
* Umständlichkeit des Tests, sondern genau das, was in der App passiert: die
* gespeicherte Wahl wirkt beim Start.
*/
import { themeStartwert } from "./theme"
import { beforeEach, describe, expect, it, vi } from "vitest"
async function startwertNachLaden(): Promise<string> {
vi.resetModules()
const { themeStartwert } = await import("./theme")
return themeStartwert()
}
describe("Theme", () => {
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")
})
})
+73
View File
@@ -0,0 +1,73 @@
/**
* Regression: die Tag/Nacht-Umschaltung blieb wirkungslos.
*
* Jeder Aufruf von `useTheme()` legte bis 2026-08-31 sein eigenes `useState`
* an. Der Schalter in den Einstellungen änderte damit nur seine eigene Kopie;
* die Kopie in `App.tsx`, die `data-theme` setzt, blieb stehen — sichtbar
* wurde die Wahl erst nach einem Neustart der App. Der Boden-Schalter hatte
* denselben Fehler.
*
* Der Test stellt genau diese Konstellation nach: zwei getrennte Komponenten,
* eine schaltet, die andere zeigt an.
*/
import { fireEvent, render, screen } from "@testing-library/react"
import { beforeEach, describe, expect, it } from "vitest"
import { useBoden, useTheme } from "./theme"
function Anzeige() {
const [theme] = useTheme()
const [boden] = useBoden()
return (
<div>
<span data-testid="theme">{theme}</span>
<span data-testid="boden">{boden ? "an" : "aus"}</span>
</div>
)
}
function Schalter() {
const [theme, setzeTheme] = useTheme()
const [boden, setzeBoden] = useBoden()
return (
<div>
<button onClick={() => setzeTheme(theme === "nacht" ? "tag" : "nacht")}>Thema</button>
<button onClick={() => setzeBoden(!boden)}>Boden</button>
</div>
)
}
describe("Ansichtseinstellungen, geteilt zwischen Komponenten", () => {
beforeEach(() => {
localStorage.clear()
})
it("die Themenwahl der einen Komponente erreicht die andere", () => {
render(
<>
<Anzeige />
<Schalter />
</>,
)
const vorher = screen.getByTestId("theme").textContent
fireEvent.click(screen.getByText("Thema"))
expect(screen.getByTestId("theme").textContent).not.toBe(vorher)
})
it("der Boden-Schalter der einen Komponente erreicht die andere", () => {
render(
<>
<Anzeige />
<Schalter />
</>,
)
const vorher = screen.getByTestId("boden").textContent
fireEvent.click(screen.getByText("Boden"))
expect(screen.getByTestId("boden").textContent).not.toBe(vorher)
})
})
+109 -70
View File
@@ -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<T>(
schluessel: string,
ausAblage: (roh: string | null) => T,
inAblage: (wert: T) => string,
) {
let wert = ausAblage(ablageLesen(schluessel))
const horcher = new Set<() => void>()
return {
stand: () => wert,
horchen: (bei: () => void) => {
horcher.add(bei)
return () => horcher.delete(bei)
},
setzen: (neu: T) => {
if (neu === wert) return
wert = neu
ablageSchreiben(schluessel, inAblage(neu))
for (const bei of horcher) bei()
},
}
}
const THEMA = einstellung<Theme>(
"dm360.theme",
(roh) => {
if (roh === "nacht" || roh === "tag") return roh
// Ohne gespeicherte Wahl entscheidet das System; ohne beides: nacht.
const hell =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-color-scheme: light)").matches
return hell ? "tag" : "nacht"
},
(wert) => wert,
)
const BODEN = einstellung<boolean>(
"dm360.boden",
(roh) => roh !== "aus",
(an) => (an ? "an" : "aus"),
)
const TABBESCHRIFTUNG = einstellung<boolean>(
"dm360.tabbeschriftung",
(roh) => roh !== "aus",
(an) => (an ? "an" : "aus"),
)
/** Nur für Tests: setzt den gehaltenen Wert auf den Stand der Ablage zurück. */
export function themeStartwert(): Theme {
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<Theme>(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<boolean>(() => {
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<boolean>(() => {
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]
}