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:
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Belege, die über das iOS-Teilen-Blatt hereinkommen.
|
||||
*
|
||||
* Die Gegenseite ist `native/teilen/ShareViewController.swift`: sie legt das
|
||||
* geteilte PDF Base64-kodiert in den UserDefaults der App-Gruppe
|
||||
* `group.app.datametric360` ab und öffnet danach die App über
|
||||
* `datametric360://beleg`.
|
||||
*
|
||||
* `@capacitor/preferences` liest genau diese UserDefaults, sobald man ihm die
|
||||
* Gruppe nennt — deshalb braucht es hier kein eigenes Plugin. Ohne die Gruppe
|
||||
* läse es den privaten Bereich der App und fände nie etwas.
|
||||
*
|
||||
* ABGEHOLT WIRD GENAU EINMAL
|
||||
* --------------------------
|
||||
* Der Eintrag wird nach dem Lesen gelöscht. Bliebe er stehen, käme derselbe
|
||||
* Beleg bei jedem Zurückkehren in die App erneut — und weil der Upload beim
|
||||
* Backend einen Vorgang anlegt, wären das echte Doppeleinträge, keine bloße
|
||||
* Anzeigewiederholung.
|
||||
*
|
||||
* Der Zeitstempel ist die zweite Sicherung: ein Eintrag, der älter ist als
|
||||
* HOECHSTALTER_MS, wird verworfen statt verarbeitet. Er kann nur entstehen,
|
||||
* wenn das Öffnen der App aus der Erweiterung heraus fehlgeschlagen ist; ihn
|
||||
* Tage später beim nächsten Start als „neuen" Beleg zu behandeln, wäre eine
|
||||
* Überraschung, keine Hilfe.
|
||||
*/
|
||||
|
||||
import { Capacitor } from "@capacitor/core"
|
||||
import { Preferences } from "@capacitor/preferences"
|
||||
|
||||
/** Muss zu `gruppe` in ShareViewController.swift passen. */
|
||||
const GRUPPE = "group.app.datametric360"
|
||||
|
||||
/** Muss zu `schluessel` in ShareViewController.swift passen. */
|
||||
const SCHLUESSEL = "dm360_geteilter_beleg"
|
||||
|
||||
/** Zehn Minuten. Länger her heißt: das Öffnen der App hat nicht geklappt. */
|
||||
const HOECHSTALTER_MS = 10 * 60 * 1000
|
||||
|
||||
let eingerichtet = false
|
||||
|
||||
async function gruppeEinrichten(): Promise<void> {
|
||||
if (eingerichtet) return
|
||||
eingerichtet = true
|
||||
try {
|
||||
await Preferences.configure({ group: GRUPPE })
|
||||
} catch (fehler) {
|
||||
// Auf Android und im Browser gibt es keine App-Gruppe; das ist kein
|
||||
// Fehler, dort kommt über diesen Weg nur nie etwas an.
|
||||
console.warn("Geteilter Beleg: Gruppe nicht einrichtbar", fehler)
|
||||
}
|
||||
}
|
||||
|
||||
function base64ZuDatei(base64: string, name: string): File {
|
||||
const roh = atob(base64)
|
||||
const bytes = new Uint8Array(roh.length)
|
||||
for (let i = 0; i < roh.length; i++) bytes[i] = roh.charCodeAt(i)
|
||||
return new File([bytes], name, { type: "application/pdf" })
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt einen wartenden Beleg ab und räumt ihn weg. `null`, wenn keiner da ist
|
||||
* — der Normalfall bei jedem gewöhnlichen App-Start.
|
||||
*/
|
||||
export async function geteiltenBelegAbholen(): Promise<File | null> {
|
||||
if (!Capacitor.isNativePlatform()) return null
|
||||
await gruppeEinrichten()
|
||||
|
||||
let roh: string | null = null
|
||||
try {
|
||||
roh = (await Preferences.get({ key: SCHLUESSEL })).value
|
||||
} catch (fehler) {
|
||||
console.warn("Geteilter Beleg: nicht lesbar", fehler)
|
||||
return null
|
||||
}
|
||||
if (!roh) return null
|
||||
|
||||
// Erst wegräumen, dann auswerten: ein unlesbarer Eintrag darf nicht bei
|
||||
// jedem Start erneut auflaufen.
|
||||
try {
|
||||
await Preferences.remove({ key: SCHLUESSEL })
|
||||
} catch {
|
||||
// Bleibt er stehen, fängt ihn beim nächsten Mal die Altersprüfung ab.
|
||||
}
|
||||
|
||||
try {
|
||||
const eintrag = JSON.parse(roh) as { name?: string; base64?: string; zeit?: number }
|
||||
if (!eintrag.base64) return null
|
||||
if (typeof eintrag.zeit === "number" && Date.now() - eintrag.zeit > HOECHSTALTER_MS) {
|
||||
return null
|
||||
}
|
||||
return base64ZuDatei(eintrag.base64, eintrag.name || "beleg.pdf")
|
||||
} catch (fehler) {
|
||||
console.warn("Geteilter Beleg: unlesbarer Eintrag verworfen", fehler)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Übergabe
|
||||
|
||||
Zwischen dem Abholen (App.tsx, beim Start und bei jeder Rückkehr) und dem
|
||||
Verarbeiten (Tanken.tsx) liegt ein Bildschirmwechsel. Ein kleiner geteilter
|
||||
Speicher nach dem Muster von theme.ts überbrückt ihn — ein Zustand, der
|
||||
außerhalb des Komponentenbaums lebt und den beide Seiten sehen. */
|
||||
|
||||
let wartend: File | null = null
|
||||
const horcher = new Set<() => void>()
|
||||
|
||||
export function geteiltenBelegSetzen(datei: File | null): void {
|
||||
wartend = datei
|
||||
for (const bei of horcher) bei()
|
||||
}
|
||||
|
||||
export function geteiltenBelegStand(): File | null {
|
||||
return wartend
|
||||
}
|
||||
|
||||
export function geteiltenBelegHorchen(bei: () => void): () => void {
|
||||
horcher.add(bei)
|
||||
return () => {
|
||||
horcher.delete(bei)
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,8 @@ export function profilZuEinstellungen(profil: Profil): Einstellungen {
|
||||
fahrzeugzusatz: text(fahrzeug, "zusatz"),
|
||||
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"),
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Beleg-PDF aus der Zwischenablage oder einer Dateiliste.
|
||||
*
|
||||
* Übernommen aus dem Panel (`belegAusZwischenablage()`, `istPdf()`,
|
||||
* `zwischenablageKnopfSinnvoll()` in audi-dashboard-app.js) — dort gibt es
|
||||
* diesen Weg seit längerem, in der App fehlte er ganz: der Beleg-Knopf öffnete
|
||||
* direkt die Dateiauswahl, und ein aus der Mail kopiertes PDF ließ sich
|
||||
* überhaupt nicht einfügen (gemeldet vom Eigentümer, 2026-08-31).
|
||||
*
|
||||
* WARUM DER KNOPF NUR AUF TIPPGERÄTEN ERSCHEINT
|
||||
* ---------------------------------------------
|
||||
* `navigator.clipboard.read()` gibt aus Datenschutzgründen nur Text, HTML und
|
||||
* Bilder heraus. Eine im Explorer oder Finder kopierte Datei taucht dort nie
|
||||
* auf — am Rechner scheitert der Weg also zwangsläufig, und ein Knopf, der
|
||||
* immer fehlschlägt, ist schlimmer als kein Knopf. Dort bleiben Ziehen und
|
||||
* Dateiauswahl.
|
||||
*
|
||||
* Auf iOS ist es umgekehrt der Hauptweg: das PDF kommt aus Mail, wird dort
|
||||
* kopiert, und das System zeigt beim Lesen seine eigene Einfügen-Abfrage.
|
||||
*/
|
||||
|
||||
/** Nicht nur auf den MIME-Typ verlassen: eine aus dem Explorer/Finder kopierte
|
||||
oder gezogene Datei kommt je nach Browser und Quelle mit leerem `type` an. */
|
||||
export function istPdf(datei: File | null | undefined): boolean {
|
||||
if (!datei) return false
|
||||
return datei.type === "application/pdf" || /\.pdf$/i.test(datei.name || "")
|
||||
}
|
||||
|
||||
/** Nimmt das erste PDF aus einer DataTransfer-/Dateiliste. */
|
||||
export function pdfAusDateiliste(dateien: FileList | File[] | null | undefined): File | null {
|
||||
return [...(dateien ?? [])].find(istPdf) ?? null
|
||||
}
|
||||
|
||||
export class ZwischenablageFehler extends Error {}
|
||||
|
||||
/**
|
||||
* Holt ein PDF aus der Zwischenablage.
|
||||
*
|
||||
* Wirft mit einem Text, der zur jeweiligen Ursache die richtige Abhilfe nennt —
|
||||
* "bitte Datei auswählen" hilft nicht weiter, wenn schlicht kein PDF kopiert
|
||||
* wurde, und "erneut kopieren" nicht, wenn der Browser die Zwischenablage gar
|
||||
* nicht herausgibt.
|
||||
*/
|
||||
export async function pdfAusZwischenablage(): Promise<File> {
|
||||
if (!navigator.clipboard?.read) {
|
||||
throw new ZwischenablageFehler(
|
||||
'Dieser Browser gibt die Zwischenablage nicht heraus - bitte "Datei auswählen".',
|
||||
)
|
||||
}
|
||||
let eintraege: ClipboardItems
|
||||
try {
|
||||
eintraege = await navigator.clipboard.read()
|
||||
} catch {
|
||||
// Verweigerte Freigabe, abgebrochene System-Abfrage, unsicherer Ursprung.
|
||||
throw new ZwischenablageFehler('Kein Zugriff auf die Zwischenablage - bitte "Datei auswählen".')
|
||||
}
|
||||
for (const eintrag of eintraege) {
|
||||
const typ = eintrag.types.find((t) => t === "application/pdf")
|
||||
if (typ) {
|
||||
const blob = await eintrag.getType(typ)
|
||||
// Ein Name muss sein: das Backend legt den Beleg unter ihm ab.
|
||||
return new File([blob], "beleg.pdf", { type: "application/pdf" })
|
||||
}
|
||||
}
|
||||
throw new ZwischenablageFehler(
|
||||
'In der Zwischenablage liegt kein PDF - bitte den Beleg erneut kopieren oder "Datei auswählen".',
|
||||
)
|
||||
}
|
||||
|
||||
/** Tippgerät? Nur dort wird der Einfügen-Knopf angeboten (Begründung oben). */
|
||||
export function zwischenablageKnopfSinnvoll(): boolean {
|
||||
return typeof window !== "undefined" && !!window.matchMedia?.("(pointer: coarse)").matches
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* „Beleg hochladen" — Vorlage: `vBelegPopup()` im Panel.
|
||||
*
|
||||
* Bis 2026-08-31 öffnete der Beleg-Knopf in der App direkt die Dateiauswahl.
|
||||
* Auf dem iPhone landete man damit im Dateispeicher, und ein aus der Mail
|
||||
* kopiertes PDF war überhaupt nicht einzufügen — im Panel gab es diesen Weg
|
||||
* längst. Geteilt zwischen dem Anlegen (Tanken.tsx) und dem Nachtragen
|
||||
* (TankDetail.tsx), damit beide nicht auseinanderlaufen.
|
||||
*
|
||||
* Ein Auswahlblatt, kein Popup: es ist eine kurze Liste von Wegen zum selben
|
||||
* Ziel, und genau dafür sieht Apple das Aktionsblatt vor. „Abbrechen" steht
|
||||
* abgesetzt in eigener Karte — das macht es zum Ausweg statt zur vierten
|
||||
* Wahlmöglichkeit.
|
||||
*
|
||||
* Welche Wege angeboten werden, entscheidet das Gerät, nicht der Geschmack:
|
||||
* am Telefon ist Einfügen der Hauptweg, am Rechner Ziehen bzw. Dateiauswahl.
|
||||
* Begründung in daten/zwischenablage.ts.
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
|
||||
import { ActionSheet } from "@audi-dash/ui"
|
||||
|
||||
import {
|
||||
ZwischenablageFehler,
|
||||
pdfAusDateiliste,
|
||||
pdfAusZwischenablage,
|
||||
zwischenablageKnopfSinnvoll,
|
||||
} from "../daten/zwischenablage"
|
||||
|
||||
export function BelegPopup({
|
||||
offen,
|
||||
beiAbbruch,
|
||||
beiDatei,
|
||||
}: {
|
||||
offen: boolean
|
||||
beiAbbruch: () => void
|
||||
/** Bekommt das PDF. Das Blatt schließt sich vorher selbst. */
|
||||
beiDatei: (datei: File) => void
|
||||
}) {
|
||||
const dateiwahl = useRef<HTMLInputElement | null>(null)
|
||||
const [fehler, setzeFehler] = useState<string | null>(null)
|
||||
const [darueber, setzeDarueber] = useState(false)
|
||||
// Einmal beim Aufbau bestimmt: ein Gerätewechsel mitten im Dialog gibt es
|
||||
// nicht, und ein bei jedem Rendern neu befragtes matchMedia wäre nur Aufwand.
|
||||
const [telefon] = useState(zwischenablageKnopfSinnvoll)
|
||||
|
||||
const uebernehmen = (datei: File) => {
|
||||
setzeFehler(null)
|
||||
beiDatei(datei)
|
||||
}
|
||||
|
||||
const einfuegen = async () => {
|
||||
try {
|
||||
uebernehmen(await pdfAusZwischenablage())
|
||||
} catch (f) {
|
||||
setzeFehler(
|
||||
f instanceof ZwischenablageFehler
|
||||
? f.message
|
||||
: 'Die Zwischenablage ließ sich nicht lesen - bitte "Datei auswählen".',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const aktionen = [
|
||||
...(telefon
|
||||
? [{ label: "Aus Zwischenablage einfügen", onClick: () => void einfuegen() }]
|
||||
: []),
|
||||
{ label: "Datei auswählen", onClick: () => dateiwahl.current?.click() },
|
||||
]
|
||||
|
||||
// Nur wenn es wirklich etwas zu zeigen gibt - sonst entstuende im Blatt
|
||||
// eine leere Zeile samt Trennlinie.
|
||||
const zusatz =
|
||||
!telefon || fehler ? (
|
||||
<>
|
||||
{!telefon && (
|
||||
<div
|
||||
className={`dm-beleg-drop${darueber ? " dm-beleg-drop--darueber" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setzeDarueber(true)
|
||||
}}
|
||||
onDragLeave={() => setzeDarueber(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setzeDarueber(false)
|
||||
const datei = pdfAusDateiliste(e.dataTransfer?.files)
|
||||
if (datei) uebernehmen(datei)
|
||||
else setzeFehler("Das war kein PDF - der Beleg muss als PDF vorliegen.")
|
||||
}}
|
||||
>
|
||||
<strong>PDF hierher ziehen</strong>
|
||||
<span>oder unten auswählen</span>
|
||||
</div>
|
||||
)}
|
||||
{fehler && (
|
||||
<p className="dm-fehler" role="alert" style={{ margin: 0 }}>
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Ausserhalb des Blattes: unsichtbar, muss aber im Baum stehen, damit
|
||||
der Verweis darauf zeigt. */}
|
||||
<input
|
||||
ref={dateiwahl}
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const datei = e.target.files?.[0]
|
||||
e.target.value = ""
|
||||
if (datei) uebernehmen(datei)
|
||||
}}
|
||||
/>
|
||||
{/* Ohne Ueberschrift und ohne Erklaerzeile: wer den Knopf 'Beleg
|
||||
hochladen' gedrueckt hat, weiss bereits, was hier passiert - der
|
||||
Kopf haette es nur wiederholt (Nutzerhinweis 2026-08-31). Die
|
||||
beiden Wege benennen sich selbst. */}
|
||||
<ActionSheet open={offen} onClose={beiAbbruch} actions={aktionen}>
|
||||
{zusatz}
|
||||
</ActionSheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Ein Datumsfeld, das seinen Wert auch dann anzeigt, wenn er erst später
|
||||
* eintrifft.
|
||||
*
|
||||
* DAS PROBLEM
|
||||
* -----------
|
||||
* Alle Datumsfelder der App hängen an Werten aus dem Datenkontext, und der
|
||||
* füllt sich asynchron: beim ersten Rendern steht dort ein leerer String, den
|
||||
* echten Wert bekommt das Feld erst danach. WebKit — die Engine der iOS-Hülle —
|
||||
* baut den inneren Aufbau eines `<input type="date">` beim ersten Rendern auf
|
||||
* und zieht ihn bei einer späteren Wertänderung von außen nicht zuverlässig
|
||||
* nach; sichtbar blieb „tt.mm.jjjj", und erst ein Antippen brachte das Datum
|
||||
* zum Vorschein (gemeldet vom Eigentümer, 2026-08-31: „erst beim ersten Klick
|
||||
* aufs Datumsfeld erscheinen diese korrekt").
|
||||
*
|
||||
* Verschärft wird das durch den `appearance`-Reset in screens.css, der für die
|
||||
* Box-Optik nötig ist — ohne ihn zeigt iOS sein eigenes, stark abgerundetes
|
||||
* Chrome („gestreckter Kreis statt Box").
|
||||
*
|
||||
* DIE LÖSUNG
|
||||
* ----------
|
||||
* Der Schlüssel wechselt genau einmal: von „leer" auf „gefuellt", sobald der
|
||||
* echte Wert da ist. React baut das Feld an dieser Stelle neu auf, und WebKit
|
||||
* legt seinen inneren Aufbau damit gleich mit dem Wert an.
|
||||
*
|
||||
* Bewusst NICHT der Wert selbst als Schlüssel: dann entstünde das Feld bei
|
||||
* jeder Auswahl neu, verlöre den Fokus und schlösse auf dem Telefon die
|
||||
* Datumsauswahl mitten in der Bedienung. Der Wechsel leer→gefüllt passiert
|
||||
* dagegen genau einmal, und zwar bevor jemand das Feld anfassen kann.
|
||||
*
|
||||
* Das Panel braucht das nicht: dort schreibt jedes `render()` das Markup neu,
|
||||
* das Feld entsteht also ohnehin immer mit seinem Wert.
|
||||
*/
|
||||
|
||||
export function Datumsfeld({
|
||||
wert,
|
||||
beiAenderung,
|
||||
className = "dm-eingabe",
|
||||
...rest
|
||||
}: {
|
||||
wert: string
|
||||
beiAenderung: (wert: string) => void
|
||||
className?: string
|
||||
} & Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"value" | "onChange" | "type" | "className"
|
||||
>) {
|
||||
return (
|
||||
<input
|
||||
key={wert ? "gefuellt" : "leer"}
|
||||
type="date"
|
||||
className={className}
|
||||
value={wert}
|
||||
onChange={(e) => beiAenderung(e.target.value)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -6,11 +6,12 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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
@@ -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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user