0fd81df700
Ziehen zum Aktualisieren funktionierte auf keinem Geraet, in keiner der beiden Anwendungen - und der Grund entwertet zugleich, wie es bisher geprueft wurde. Beide trieben die Geste ueber pointermove und riefen dort preventDefault(). Das verhindert laut Spezifikation kein Scrollen; darueber entscheidet allein touch-action. Auf echtem Touch uebernimmt der Browser die senkrechte Geste, schickt pointercancel und stellt pointermove ein - der Zug wird zurueckgesetzt, lange bevor er die Schwelle erreicht. Eine Maus kennt diese Uebernahme nicht, synthetische PointerEvents ebenso wenig: genau deshalb lief die Geste 2026-08-17 im Test sauber durch und am Geraet nie. Jetzt laeuft sie auf Touch-Ereignissen, touchmove zwingend mit passive:false (sonst ist preventDefault() wirkungslos); der Zeigerweg bleibt fuer Maus und Stift und ignoriert pointerType "touch". Mit echten TouchEvents geprueft, inklusive defaultPrevented - dem einzigen Signal, das belegt, dass das native Ueberscrollen wirklich unterbunden ist. Der zuletzt nicht nachstellbare Befund war nachtmodus-spezifisch: --tile ist dort ein durchscheinender Schleier, und die Wischzeilen malten ihn ein zweites Mal auf die Kachel, auf der sie ohnehin liegen. Am Tag sind beide deckend weiss, deshalb war nichts zu sehen. Das Panel nutzt an dieser Stelle --tile-deckend; dieses Token fehlte im Design-System und ist jetzt da. Ausserdem: steht das Auto auf einem benannten Platz, zeigen beide dessen Namen statt der naechsten Hausnummer - Nominatim liefert ihn mit, die Pruefung auf die Art der Flaeche verhindert, dass irgendein benanntes Gebaeude die Anschrift verdraengt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
/**
|
|
* Rahmen der App: Kopfzeile, Navigation, Inhaltsfläche.
|
|
*
|
|
* Zwei Layouts aus derselben Zusammensetzung — die entscheidende Anforderung
|
|
* aus dem Design-Auftrag: schmal (Handy) mit Tab-Leiste unten, breit (Tablet,
|
|
* eingebettet in Home Assistant) mit Seitenleiste links. Der Umschaltpunkt
|
|
* liegt bei 1000px.
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from "react"
|
|
import type { PointerEvent as ReactPointerEvent, ReactNode, RefObject } from "react"
|
|
|
|
import { TabBar } from "@audi-dash/ui"
|
|
|
|
import { standAlter } from "./format"
|
|
import { useBreiterBildschirm } from "./hooks/useBreiterBildschirm"
|
|
import { VierRinge } from "./screens/Typenschild"
|
|
import type { Hauptbereich, Route, SeitenName } from "./navigation"
|
|
import { TITEL, ZURUECK, bereichVon, istHauptbereich } from "./navigation"
|
|
import {
|
|
SymbolAktualisieren,
|
|
SymbolAudi,
|
|
SymbolFahrten,
|
|
SymbolStatistik,
|
|
SymbolTanken,
|
|
SymbolUebersicht,
|
|
SymbolZahnrad,
|
|
SymbolZurueck,
|
|
} from "./symbole"
|
|
|
|
export interface ShellProps {
|
|
route: Route
|
|
geheZu: (name: SeitenName, id?: string) => void
|
|
tabBeschriftung: boolean
|
|
/** Wird über der Kopfzeile eingeblendet (Offline-Hinweis, Warteschlange). */
|
|
hinweis?: ReactNode | undefined
|
|
/** Zeitpunkt des letzten Datenstands — steht als „vor 3 Std." unter dem Titel. */
|
|
statusStand?: string | null | undefined
|
|
/** Neuabfrage beim Fahrzeug, ausgelöst durch Ziehen zum Aktualisieren. */
|
|
beiAktualisieren?: (() => Promise<void>) | undefined
|
|
/** Heißt `children`, weil JSX-Verschachtelung genau diesen Namen erwartet. */
|
|
children: ReactNode
|
|
}
|
|
|
|
/** Ziehen zum Aktualisieren, 1:1 nach der ptr-Logik des Panels. */
|
|
const PTR_SCHWELLE = 56
|
|
|
|
function useZiehenAktualisieren(
|
|
inhalt: RefObject<HTMLElement | null>,
|
|
beiAktualisieren: (() => Promise<void>) | undefined,
|
|
) {
|
|
const [zug, setzeZug] = useState(0)
|
|
const [laedt, setzeLaedt] = useState(false)
|
|
const start = useRef<number | null>(null)
|
|
|
|
const beenden = () => {
|
|
if (start.current === null || !beiAktualisieren) return
|
|
start.current = null
|
|
if (zug >= PTR_SCHWELLE) {
|
|
setzeLaedt(true)
|
|
void beiAktualisieren().finally(() => {
|
|
setzeLaedt(false)
|
|
setzeZug(0)
|
|
})
|
|
} else {
|
|
setzeZug(0)
|
|
}
|
|
}
|
|
|
|
const starten = (y: number) => {
|
|
if (laedt) return
|
|
if ((inhalt.current?.scrollTop ?? 0) > 0) return
|
|
start.current = y
|
|
setzeZug(0)
|
|
}
|
|
/* Gibt true zurück, wenn die Geste uns gehört — der Aufrufer muss dann
|
|
preventDefault() rufen. */
|
|
const bewegen = (y: number): boolean => {
|
|
if (start.current === null) return false
|
|
const dy = y - start.current
|
|
if (dy > 0 && (inhalt.current?.scrollTop ?? 0) <= 0) {
|
|
// Widerstand wie bei iOS: halbe Strecke, gedeckelt bei 90px.
|
|
setzeZug(Math.min(90, dy * 0.5))
|
|
return true
|
|
}
|
|
start.current = null
|
|
setzeZug(0)
|
|
return false
|
|
}
|
|
|
|
/* Auf dem Finger läuft die Geste über Touch-Ereignisse, nicht über Pointer.
|
|
Grund: preventDefault() auf einem Pointer-Ereignis verhindert laut
|
|
Spezifikation kein Scrollen — darüber entscheidet allein touch-action. Auf
|
|
einem echten Gerät übernimmt der Browser die senkrechte Geste deshalb
|
|
sofort, schickt pointercancel und stellt pointermove ein; der Zug wurde
|
|
zurückgesetzt, bevor er die Schwelle erreichte. Am Rechner fällt das nie
|
|
auf, eine Maus kennt keine Scroll-Übernahme. Der Listener muss von Hand
|
|
und mit passive:false hängen — React registriert touchmove passiv, dort
|
|
wäre preventDefault() wirkungslos. */
|
|
useEffect(() => {
|
|
const el = inhalt.current
|
|
// Der Aussteiger sitzt IM Effekt, nicht davor: ein vorgezogenes return
|
|
// wuerde diesen Hook je nach Bildschirmbreite mal aufrufen und mal nicht
|
|
// und damit die Hook-Reihenfolge brechen.
|
|
if (!el || !beiAktualisieren) return undefined
|
|
const beiStart = (e: TouchEvent) => {
|
|
if (e.touches.length === 1) starten(e.touches[0]!.clientY)
|
|
}
|
|
const beiBewegung = (e: TouchEvent) => {
|
|
if (e.touches.length !== 1) return
|
|
if (bewegen(e.touches[0]!.clientY)) e.preventDefault()
|
|
}
|
|
el.addEventListener("touchstart", beiStart, { passive: true })
|
|
el.addEventListener("touchmove", beiBewegung, { passive: false })
|
|
el.addEventListener("touchend", beenden)
|
|
el.addEventListener("touchcancel", beenden)
|
|
return () => {
|
|
el.removeEventListener("touchstart", beiStart)
|
|
el.removeEventListener("touchmove", beiBewegung)
|
|
el.removeEventListener("touchend", beenden)
|
|
el.removeEventListener("touchcancel", beenden)
|
|
}
|
|
})
|
|
|
|
if (!beiAktualisieren) {
|
|
return { zug: 0, laedt: false, handler: {} as Record<string, never> }
|
|
}
|
|
|
|
return {
|
|
zug,
|
|
laedt,
|
|
handler: {
|
|
// Nur Maus/Stift — der Finger läuft über die Touch-Ereignisse oben.
|
|
onPointerDown: (e: ReactPointerEvent) => {
|
|
if (!e.isPrimary || e.pointerType === "touch") return
|
|
starten(e.clientY)
|
|
},
|
|
onPointerMove: (e: ReactPointerEvent) => {
|
|
if (!e.isPrimary || e.pointerType === "touch") return
|
|
bewegen(e.clientY)
|
|
},
|
|
onPointerUp: beenden,
|
|
onPointerCancel: beenden,
|
|
},
|
|
}
|
|
}
|
|
|
|
const BEREICHE: { key: Hauptbereich; label: string; icon: ReactNode }[] = [
|
|
{ key: "home", label: "Übersicht", icon: <SymbolUebersicht /> },
|
|
{ key: "audi", label: "Mein Audi", icon: <SymbolAudi /> },
|
|
{ key: "trips", label: "Fahrten", icon: <SymbolFahrten /> },
|
|
{ key: "stat", label: "Statistik", icon: <SymbolStatistik /> },
|
|
{ key: "fuel", label: "Tanken", icon: <SymbolTanken /> },
|
|
]
|
|
|
|
export function Shell({
|
|
route,
|
|
geheZu,
|
|
tabBeschriftung,
|
|
hinweis,
|
|
statusStand,
|
|
beiAktualisieren,
|
|
children,
|
|
}: ShellProps) {
|
|
const breit = useBreiterBildschirm()
|
|
const aktiverBereich = bereichVon(route.name)
|
|
const zurueckZiel = ZURUECK[route.name]
|
|
|
|
const unterseite = !istHauptbereich(route.name)
|
|
const inhaltRef = useRef<HTMLElement | null>(null)
|
|
const ptr = useZiehenAktualisieren(inhaltRef, breit ? undefined : beiAktualisieren)
|
|
|
|
// Der Text tickt mit, wie standAlterTicken() im Panel (Sekundentakt).
|
|
const [, neuZeichnen] = useState(0)
|
|
useEffect(() => {
|
|
if (route.name !== "home") return undefined
|
|
const uhr = window.setInterval(() => neuZeichnen((n) => n + 1), 1000)
|
|
return () => window.clearInterval(uhr)
|
|
}, [route.name])
|
|
|
|
const kopf = (
|
|
<header className={unterseite ? "dm-kopf dm-kopf--unterseite" : "dm-kopf"}>
|
|
{zurueckZiel && !breit ? (
|
|
<button
|
|
type="button"
|
|
className="dm-kopf__zurueck"
|
|
aria-label="Zurück"
|
|
onClick={() => geheZu(zurueckZiel)}
|
|
>
|
|
<SymbolZurueck groesse={22} />
|
|
</button>
|
|
) : null}
|
|
{/* Kein Eyebrow: das Panel blendet seinen (`.eyebrow{display:none}` in
|
|
audi-dashboard-ios.css) auf Telefonbreite vollständig aus - der feste
|
|
Text "DataMetric360" stand hier über jedem Bildschirm, im Panel steht
|
|
dort nichts. */}
|
|
<div className="dm-kopf__titel">
|
|
<div className="ads-title">{TITEL[route.name]}</div>
|
|
{/* Datenstand nur auf der Übersicht, wie `#sync` im Panel. */}
|
|
{route.name === "home" && (
|
|
<div className="dm-stand">
|
|
<SymbolAktualisieren groesse={13} />
|
|
<span>{standAlter(statusStand)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* `kopf` wird nur in der schmalen Spalte gerendert (breit hat seinen
|
|
eigenen Seitenleisten-Kopf, siehe unten) - dort sind die Audi-Ringe
|
|
oben rechts der einzige Zugang zu Einstellungen, das Zahnrad
|
|
entfällt, "damit oben rechts nur ein Element sitzt" (Design-Audit
|
|
2026-08-13, siehe .rings/.profilbtn in audi-dashboard-ios.css).
|
|
Nur auf den Tab-Ebenen: auf Unterseiten blendet das Panel die Ringe
|
|
aus (`#marke`.style.display in render()), dort steht links der
|
|
Zurück-Pfeil. */}
|
|
{!unterseite ? (
|
|
<button
|
|
type="button"
|
|
className="dm-kopf__aktion"
|
|
aria-label="Einstellungen"
|
|
onClick={() => geheZu("einst")}
|
|
>
|
|
<VierRinge hoehe={17} />
|
|
</button>
|
|
) : null}
|
|
</header>
|
|
)
|
|
|
|
if (breit) {
|
|
return (
|
|
<>
|
|
<nav className="dm-seitenleiste" aria-label="Hauptbereiche">
|
|
<div className="dm-seitenleiste__marke">
|
|
<VierRinge hoehe={18} />
|
|
<span className="ads-eyebrow">DataMetric360</span>
|
|
</div>
|
|
{BEREICHE.map((bereich) => (
|
|
<button
|
|
key={bereich.key}
|
|
type="button"
|
|
className={
|
|
bereich.key === aktiverBereich
|
|
? "dm-seitenleiste__punkt dm-seitenleiste__punkt--an"
|
|
: "dm-seitenleiste__punkt"
|
|
}
|
|
aria-current={bereich.key === aktiverBereich ? "page" : undefined}
|
|
onClick={() => geheZu(bereich.key)}
|
|
>
|
|
{bereich.icon}
|
|
<span>{bereich.label}</span>
|
|
</button>
|
|
))}
|
|
<div className="dm-seitenleiste__fuss">
|
|
<button
|
|
type="button"
|
|
className={
|
|
route.name === "einst"
|
|
? "dm-seitenleiste__punkt dm-seitenleiste__punkt--an"
|
|
: "dm-seitenleiste__punkt"
|
|
}
|
|
onClick={() => geheZu("einst")}
|
|
>
|
|
<SymbolZahnrad />
|
|
<span>Einstellungen</span>
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
<div className="dm-spalte">
|
|
{hinweis}
|
|
{!istHauptbereich(route.name) && zurueckZiel ? (
|
|
<div className="dm-brotkrume">
|
|
<button type="button" onClick={() => geheZu(zurueckZiel)}>
|
|
<SymbolZurueck groesse={16} />
|
|
<span>{TITEL[zurueckZiel]}</span>
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
<main className="dm-scroll dm-inhalt dm-inhalt--breit">{children}</main>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="dm-spalte">
|
|
{hinweis}
|
|
{kopf}
|
|
{/* Ziehen zum Aktualisieren wie im Panel (`.ptr`): das Band wächst
|
|
während der Geste aus der Höhe 0 heraus. */}
|
|
<div
|
|
className={ptr.laedt ? "dm-ptr dm-ptr--dreht" : "dm-ptr"}
|
|
style={{ height: ptr.laedt ? "56px" : `${ptr.zug}px` }}
|
|
aria-hidden={ptr.zug === 0 && !ptr.laedt}
|
|
>
|
|
<SymbolAktualisieren groesse={14} />
|
|
<span>
|
|
{ptr.laedt
|
|
? "Aktualisieren …"
|
|
: ptr.zug >= 56
|
|
? "Loslassen zum Aktualisieren"
|
|
: "Ziehen zum Aktualisieren"}
|
|
</span>
|
|
</div>
|
|
<main className="dm-scroll dm-inhalt" ref={inhaltRef} {...ptr.handler}>
|
|
{children}
|
|
</main>
|
|
<TabBar
|
|
items={BEREICHE}
|
|
activeKey={aktiverBereich}
|
|
onChange={(schluessel) => geheZu(schluessel as Hauptbereich)}
|
|
iconOnly={!tabBeschriftung}
|
|
className="dm-tableiste"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|