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 <[email protected]>
This commit is contained in:
2026-08-31 14:24:52 +02:00
co-authored by Claude Opus 5
parent 26f72930c3
commit b1411625aa
58 changed files with 2846 additions and 368 deletions
@@ -33,18 +33,34 @@ export function ImagePlaceholder({
size = "default",
className,
}: ImagePlaceholderProps) {
const [failed, setFailed] = React.useState(false);
/*
* Gemerkt wird die Adresse, die fehlgeschlagen ist - nicht ein blosses
* "ist fehlgeschlagen".
*
* Der Unterschied ist der Fehler, den das behebt: In der Fotogalerie unter
* "Mein Audi" bleibt dieselbe Komponente stehen und bekommt nur eine neue
* src. Mit einem blossen Wahrheitswert blieb ein einmal gesetztes true fuer
* immer stehen - sobald ein noch leerer Platz durchlaufen war, zeigten auch
* alle folgenden Plaetze mit echtem Foto nur noch den Platzhalter. Erst ein
* Seitenwechsel setzte den Zustand zurueck, weil die Komponente dabei neu
* entsteht (gemeldet vom Eigentuemer, 2026-08-31).
*
* Bewusst ohne useEffect: ein Vergleich beim Rendern kommt ohne den
* zusaetzlichen Durchlauf aus, den ein Zuruecksetzen im Effekt braucht -
* und in dem waere fuer einen Wimpernschlag das falsche Bild zu sehen.
*/
const [fehlgeschlagen, setzeFehlgeschlagen] = React.useState<string | null>(null);
const classes = ["ads-bildbox"];
if (size === "mini") classes.push("ads-bildbox--mini");
if (size === "round") classes.push("ads-bildbox--round");
if (className) classes.push(className);
const showPlaceholder = !src || failed;
const showPlaceholder = !src || fehlgeschlagen === src;
return (
<div className={classes.join(" ")}>
{!showPlaceholder && (
<img src={src} alt={alt} onError={() => setFailed(true)} />
<img src={src} alt={alt} onError={() => setzeFehlgeschlagen(src)} />
)}
{showPlaceholder && (
<div className="ads-platzhalter">
@@ -18,3 +18,15 @@
color: var(--red);
border-color: var(--red);
}
/* Als Knopf: die Optik bleibt exakt die der Anzeige-Pille, nur die
Browser-Vorgaben fuer <button> werden zurueckgenommen. Deckungsgleich mit
`.pill` im Panel, wo dieselbe Pille mit style="cursor:pointer;
font-family:inherit" als Knopf gerendert wird. */
.ads-pill--knopf {
font: inherit;
font-size: 12.5px;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
+24 -1
View File
@@ -5,11 +5,34 @@ export interface PillProps {
/** "work" recolors border+text to the accent red - use for a highlighted tag. */
variant?: "default" | "work";
className?: string;
/**
* Macht aus der Pille einen Knopf.
*
* Nicht kosmetisch: im Panel ist die Art-Pille einer Fahrt seit jeher ein
* `<button data-art>`, das zwischen Privat und Arbeitsweg umschaltet. Ohne
* diesen Zweig war sie in der App ein totes `<span>` - dieselbe Optik, aber
* ohne die Bedienung, was von aussen wie ein Fehler aussieht und es de facto
* auch war.
*
* Fehlt onClick, bleibt es beim `<span>`: eine Pille, die nur etwas anzeigt,
* soll auch nicht per Tabulator erreichbar sein.
*/
onClick?: () => void;
/** Nur mit onClick sinnvoll - beschreibt die Wirkung, nicht den Zustand. */
ariaLabel?: string;
}
export function Pill({ children, variant = "default", className }: PillProps) {
export function Pill({ children, variant = "default", className, onClick, ariaLabel }: PillProps) {
const classes = ["ads-pill"];
if (variant === "work") classes.push("ads-pill--work");
if (onClick) classes.push("ads-pill--knopf");
if (className) classes.push(className);
if (onClick) {
return (
<button type="button" className={classes.join(" ")} onClick={onClick} aria-label={ariaLabel}>
{children}
</button>
);
}
return <span className={classes.join(" ")}>{children}</span>;
}
+21 -1
View File
@@ -48,7 +48,27 @@ export function Popup({
</>
);
const target = portalTarget ?? (typeof document !== "undefined" ? document.body : null);
/*
* Ziel des Portals ist die App-Wurzel, NICHT document.body.
*
* Die Farbwerte haengen an [data-theme] auf dem .ads-root-Element (siehe
* tokens/tokens.css: :root traegt den Nachtmodus, [data-theme="tag"]
* ueberschreibt ihn). Ein nach document.body portaliertes Fenster steht
* ausserhalb dieses Elements und erbt damit immer den Nachtmodus - im
* Tagmodus gemessen am 2026-08-31: innerhalb --tile:#FFFFFF, auf body
* --tile:rgba(255,255,255,.055). Das Fenster war dadurch eine fast
* durchsichtige Flaeche auf weissem Grund, ohne erkennbare Kante.
*
* Das betraf jedes Popup der App gleichermassen und war kein Fehler der
* einzelnen Aufrufe - deshalb sitzt die Korrektur hier und nicht dort.
*
* position:fixed bleibt davon unberuehrt: .ads-root setzt weder transform
* noch filter, der umschliessende Block bleibt also das Sichtfenster, und
* ein overflow:hidden weiter oben schneidet ein solches Element nicht ab.
*/
const target =
portalTarget ??
(typeof document !== "undefined" ? (document.querySelector(".ads-root") ?? document.body) : null);
return target ? createPortal(content, target) : content;
}
@@ -0,0 +1,98 @@
import * as React from "react";
import { createPortal } from "react-dom";
import { sheetPortalTarget, useEscape } from "./Sheet";
/**
* Action sheet - the iOS pattern for a short set of choices the user asked for.
*
* Two groups, exactly as Apple describes them: the choices in one rounded
* card, "Cancel" alone in a second one below it with a gap between. The gap is
* not decoration - it is what makes Cancel unmistakably the way out rather
* than one more option in the list.
*
* Deliberately identical in geometry to `.sheet` in the panel's
* audi-dashboard.css, which already implemented this pattern: 10px side
* margins, 16px corners, 52px minimum row height, one hairline between rows,
* opaque surface. Two codebases, one look.
*
* Use `Sheet` instead when the content is a form rather than a list of
* choices.
*/
export interface ActionSheetAktion {
label: React.ReactNode;
onClick: () => void;
/** Red, for anything that removes or overwrites something. */
destructive?: boolean;
disabled?: boolean;
}
export interface ActionSheetProps {
open: boolean;
onClose: () => void;
/** Optional heading above the choices. */
title?: React.ReactNode;
/** One explanatory line under the title. */
description?: React.ReactNode;
actions: ActionSheetAktion[];
/** Label of the separated bottom button. Pass null to leave it out. */
cancelLabel?: string | null;
/** Rendered between the heading and the choices - an error line, a hint. */
children?: React.ReactNode;
className?: string;
portalTarget?: Element;
}
export function ActionSheet({
open,
onClose,
title,
description,
actions,
cancelLabel = "Abbrechen",
children,
className,
portalTarget,
}: ActionSheetProps) {
useEscape(open, onClose);
const target = sheetPortalTarget(portalTarget);
if (!open || !target) return null;
const classes = ["ads-aktionsblatt"];
if (className) classes.push(className);
return createPortal(
<>
<div className="ads-sheet-scrim" onClick={onClose} />
<div className={classes.join(" ")} role="dialog" aria-modal="true">
<div className="ads-aktionsblatt__gruppe">
{(title || description) && (
<div className="ads-aktionsblatt__kopf">
{title && <strong>{title}</strong>}
{description && <span>{description}</span>}
</div>
)}
{children && <div className="ads-aktionsblatt__zusatz">{children}</div>}
{actions.map((aktion, i) => (
<button
key={i}
type="button"
className={aktion.destructive ? "ads-aktionsblatt__loeschen" : undefined}
disabled={aktion.disabled}
onClick={aktion.onClick}
>
{aktion.label}
</button>
))}
</div>
{cancelLabel !== null && (
<button type="button" className="ads-aktionsblatt__abbrechen" onClick={onClose}>
{cancelLabel}
</button>
)}
</div>
</>,
target,
);
}
@@ -0,0 +1,214 @@
/* -------------------------------------------------------------- Blätter
Zwei Bausteine, ein Muster: etwas Modales kommt von der unteren Kante,
nicht aus der Bildschirmmitte. Apples Vorgabe reserviert die schwebende
Karte (Popover) für große Bildschirme; auf dem Telefon soll daraus ein
Blatt werden.
Der abdunkelnde Schleier gehört dazu und ist nicht Geschmack: modal heißt,
dass der Inhalt dahinter nicht bedienbar ist - das soll man sehen. Die alte
.ads-popup-Fassung hatte gar keinen (background:none), die Karte schwebte
ohne Trennung über der noch voll sichtbaren Seite. */
.ads-sheet-scrim {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, .45);
border: none;
animation: ads-blatt-blende .18s ease-out;
}
@keyframes ads-blatt-blende {
from { opacity: 0 }
to { opacity: 1 }
}
@keyframes ads-blatt-hoch {
from { transform: translateY(100%) }
to { transform: none }
}
@keyframes ads-blatt-rein {
from { transform: translateY(18px); opacity: 0 }
to { transform: none; opacity: 1 }
}
/* ------------------------------------------------------------- Formular */
.ads-sheet {
position: fixed;
z-index: 1001;
left: 0;
right: 0;
bottom: 0;
/* Blickdicht, nie durchscheinend: --tile-2 ist im Nachtthema ein rgba-Weiß
für Kachel-auf-Kachel und ließ die Seite durch den Dialog schimmern.
Dieselbe Korrektur trägt das Panel längst in .sheet-gruppe und
.setup-popup. */
background: var(--tile-deckend, var(--canvas));
/* Nur oben rund - das Blatt sitzt auf der Kante auf. */
border-radius: 16px 16px 0 0;
padding: 0 20px max(20px, env(safe-area-inset-bottom));
max-height: 88%;
display: flex;
flex-direction: column;
animation: ads-blatt-hoch .28s cubic-bezier(.22, .61, .36, 1);
}
/* Breite Bildschirme: ein Blatt über die volle Breite eines Monitors wäre
ein Band, kein Dialog. Dieselbe Obergrenze wie die Popups des Panels. */
@media (min-width: 700px) {
.ads-sheet {
max-width: 560px;
margin-left: auto;
margin-right: auto;
border-radius: 16px;
bottom: 20px;
left: 20px;
right: 20px;
}
}
/* Greifer: das sichtbare Versprechen, dass sich das Blatt wegschieben lässt.
Maße wie der Griff des Standort-Blattes (36 x 4). */
.ads-sheet__griff {
display: block;
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--line-strong);
margin: 8px auto 4px;
flex: none;
}
.ads-sheet__kopf {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 0 12px;
flex: none;
}
.ads-sheet__kopf strong {
font-size: 17px;
font-weight: 400;
color: var(--fg);
}
.ads-sheet__kopf span {
font-size: 13px;
line-height: 1.45;
color: var(--fg2);
}
/* Der Inhalt scrollt, nicht das Blatt: der Greifer und die Überschrift sollen
stehen bleiben, wenn ein Formular länger wird als der Bildschirm. */
.ads-sheet__inhalt {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* ------------------------------------------------------------- Auswahl */
.ads-aktionsblatt {
position: fixed;
z-index: 1001;
left: 10px;
right: 10px;
bottom: max(10px, env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: 8px;
animation: ads-blatt-rein .24s cubic-bezier(.22, .61, .36, 1);
}
@media (min-width: 700px) {
.ads-aktionsblatt {
max-width: 560px;
margin-left: auto;
margin-right: auto;
}
}
.ads-aktionsblatt__gruppe {
background: var(--tile-deckend, var(--canvas));
border-radius: 16px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.ads-aktionsblatt__kopf {
padding: 15px 18px 14px;
border-bottom: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 4px;
}
.ads-aktionsblatt__kopf strong {
font-size: 15px;
font-weight: 400;
color: var(--fg);
}
.ads-aktionsblatt__kopf span {
font-size: 13px;
color: var(--fg2);
line-height: 1.45;
}
.ads-aktionsblatt__zusatz {
padding: 12px 18px;
border-bottom: 1px solid var(--line);
}
.ads-aktionsblatt__gruppe button {
background: none;
border: none;
font-family: inherit;
font-size: 16px;
color: var(--fg);
padding: 16px 18px;
min-height: 52px;
text-align: left;
cursor: pointer;
border-bottom: 1px solid var(--line);
}
.ads-aktionsblatt__gruppe button:last-child {
border-bottom: none;
}
.ads-aktionsblatt__gruppe button:active {
background: var(--shade);
}
.ads-aktionsblatt__gruppe button:disabled {
color: var(--fg3);
cursor: default;
}
.ads-aktionsblatt__loeschen {
color: var(--bad);
}
/* Eigene Karte mit Abstand darüber - das ist es, was "Abbrechen" vom Rest der
Liste trennt und es unmissverständlich zum Ausweg macht, statt zu einer
weiteren Wahlmöglichkeit. */
.ads-aktionsblatt__abbrechen {
background: var(--tile-deckend, var(--canvas));
border: none;
border-radius: 16px;
font-family: inherit;
font-size: 16px;
color: var(--fg);
padding: 16px;
min-height: 52px;
cursor: pointer;
}
.ads-aktionsblatt__abbrechen:active {
background: var(--shade);
}
@@ -0,0 +1,103 @@
import * as React from "react";
import { createPortal } from "react-dom";
/**
* Modal sheet - the iOS pattern for a task that needs input.
*
* WHY THIS REPLACES `Popup`
* -------------------------
* `Popup` renders a card floating in the middle of the screen. On iPhone that
* is a popover, and Apple's Human Interface Guidelines reserve popovers for
* larger screens: on a phone they are expected to adapt into a sheet that
* slides up from the bottom edge. A centred card is also harder to reach with
* a thumb, and it gave no hint of how to get rid of it.
*
* The card was translucent on top of that (`--tile-2` is an rgba white in the
* night theme, meant for tile-on-tile layering) so page content showed through
* the dialog. The panel had already hit this and fixed it for its own sheet
* and setup dialog by switching to the opaque surface token; this component
* starts there.
*
* WHAT MAKES IT A SHEET
* ---------------------
* - anchored to the bottom edge, rounded top corners only
* - a grabber, so it reads as draggable and dismissible
* - a dimmed scrim: modal means the content behind is out of reach, and it
* should look that way
* - opaque surface, never see-through
* - the bottom safe area stays clear of the home indicator
* - Escape closes it, and so does a tap on the scrim
*
* `ActionSheet` is the sibling for a set of choices; use that one when the
* sheet is a list of buttons rather than a form.
*/
/**
* Portal target for both sheet components: the themed app root, never
* `document.body`. The colour tokens hang off `[data-theme]` on `.ads-root`;
* anything portalled outside it inherits the night palette even while the app
* shows the day one (measured 2026-08-31 - see Popup.tsx).
*/
export function sheetPortalTarget(portalTarget?: Element): Element | null {
if (portalTarget) return portalTarget;
if (typeof document === "undefined") return null;
return document.querySelector(".ads-root") ?? document.body;
}
/** Escape closes the topmost modal - expected wherever there is a keyboard. */
export function useEscape(open: boolean, onClose: () => void): void {
React.useEffect(() => {
if (!open) return;
const beiTaste = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", beiTaste);
return () => document.removeEventListener("keydown", beiTaste);
}, [open, onClose]);
}
export interface SheetProps {
open: boolean;
onClose: () => void;
/** Shown at the top of the sheet, above the content. */
title?: React.ReactNode;
/** One explanatory line under the title. */
description?: React.ReactNode;
children: React.ReactNode;
className?: string;
portalTarget?: Element;
}
export function Sheet({
open,
onClose,
title,
description,
children,
className,
portalTarget,
}: SheetProps) {
useEscape(open, onClose);
const target = sheetPortalTarget(portalTarget);
if (!open || !target) return null;
const classes = ["ads-sheet"];
if (className) classes.push(className);
return createPortal(
<>
<div className="ads-sheet-scrim" onClick={onClose} />
<div className={classes.join(" ")} role="dialog" aria-modal="true">
<span className="ads-sheet__griff" aria-hidden="true" />
{(title || description) && (
<div className="ads-sheet__kopf">
{title && <strong>{title}</strong>}
{description && <span>{description}</span>}
</div>
)}
<div className="ads-sheet__inhalt">{children}</div>
</div>
</>,
target,
);
}
@@ -0,0 +1,4 @@
export { Sheet, sheetPortalTarget, useEscape } from "./Sheet";
export type { SheetProps } from "./Sheet";
export { ActionSheet } from "./ActionSheet";
export type { ActionSheetProps, ActionSheetAktion } from "./ActionSheet";
+3
View File
@@ -46,6 +46,9 @@ export type { SwipeRowProps } from "./components/SwipeRow";
export { TabBar } from "./components/TabBar";
export type { TabBarProps, TabBarItem } from "./components/TabBar";
export { Sheet, ActionSheet } from "./components/Sheet";
export type { SheetProps, ActionSheetProps, ActionSheetAktion } from "./components/Sheet";
export { Popup, PopupMenuItem } from "./components/Popup";
export type { PopupProps, PopupMenuItemProps, PopupAnchor } from "./components/Popup";
+1
View File
@@ -18,5 +18,6 @@
@import "./components/Accordion/Accordion.css";
@import "./components/SwipeRow/SwipeRow.css";
@import "./components/TabBar/TabBar.css";
@import "./components/Sheet/Sheet.css";
@import "./components/Popup/Popup.css";
@import "./components/ImagePlaceholder/ImagePlaceholder.css";