Files
audi-app/companion-app/src/Shell.tsx
T
tobias 0fd81df700 Ziehen zum Aktualisieren, Nachtmodus-Zeilen, Parkplatznamen (2026.8.30.10)
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>
2026-08-30 23:22:52 +02:00

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>
)
}