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
+109 -70
View File
@@ -1,58 +1,128 @@
/**
* Tag/Nacht-Umschaltung, übernommen aus dem alten Panel (A6 des Design-Audits):
* gespeicherte Wahl schlägt Systemeinstellung, Standard ist "nacht".
* Alle localStorage-Zugriffe sind abgesichert, weil sie im Privatmodus mancher
* Browser werfen.
* Ansichtseinstellungen: Tag/Nacht, Fahrbahn unter dem Fahrzeug, Beschriftung
* der Menüleiste. Übernommen aus dem alten Panel (A6 des Design-Audits):
* gespeicherte Wahl schlägt Systemeinstellung, Standard für das Thema ist
* „nacht".
*
* WARUM HIER EIN GETEILTER SPEICHER STEHT UND NICHT DREIMAL useState
* ------------------------------------------------------------------
* Bis 2026-08-31 legte jeder dieser Haken sein eigenes `useState` an. Wer ihn
* zweimal aufrief, bekam zwei voneinander unabhängige Kopien desselben Werts:
*
* useTheme() → App.tsx (setzt data-theme), Einstellungen.tsx (der Schalter),
* Typenschild.tsx (zweimal, für die Farbe des Schilds)
* useBoden() → Einstellungen.tsx (der Schalter), Uebersicht.tsx (die Anzeige)
*
* Der Schalter in den Einstellungen änderte damit nur seine eigene Kopie und
* den Speicher der Ablage — die Kopie in App.tsx blieb stehen, `data-theme`
* wechselte nie, und die Umschaltung wirkte erst nach einem Neustart der App.
* Genau so gemeldet: „Tag/Nacht Umschaltung wirkungslos". Der Boden-Schalter
* hatte denselben Fehler, nur ist er weniger auffällig.
*
* `useSyncExternalStore` (in React eingebaut) löst das an der Wurzel: es gibt
* je Einstellung genau einen Wert, jeder Aufrufer sieht denselben, und ein
* Setzen benachrichtigt alle. Das ist der React-eigene Weg für einen Zustand,
* der außerhalb des Komponentenbaums lebt — kein Kontext-Anbieter nötig, und
* die Aufrufstellen bleiben unverändert.
*
* Alle Zugriffe auf die Ablage sind abgesichert: im Privatmodus mancher
* Browser wirft schon das Lesen.
*/
import { useCallback, useEffect, useState } from "react"
import { useCallback, useEffect, useSyncExternalStore } from "react"
export type Theme = "nacht" | "tag"
const SCHLUESSEL = "dm360.theme"
function gespeichertLesen(): Theme | null {
function ablageLesen(schluessel: string): string | null {
try {
const wert = localStorage.getItem(SCHLUESSEL)
return wert === "nacht" || wert === "tag" ? wert : null
return localStorage.getItem(schluessel)
} catch {
return null
}
}
function gespeichertSchreiben(theme: Theme): void {
function ablageSchreiben(schluessel: string, wert: string): void {
try {
localStorage.setItem(SCHLUESSEL, theme)
localStorage.setItem(schluessel, wert)
} catch {
// Privatmodus — die Wahl gilt dann nur für diese Sitzung
// Privatmodus — die Wahl gilt dann nur für diese Sitzung.
}
}
/**
* Ein Wert, den sich alle Aufrufer teilen.
*
* `wert` wird beim ersten Zugriff aus der Ablage gelesen und danach im
* Speicher gehalten — `useSyncExternalStore` verlangt, dass zwei Aufrufe von
* `stand()` ohne zwischenzeitliche Änderung dasselbe liefern. Bei jedem Mal
* neu aus der Ablage zu lesen genügt dem zwar auch, aber nur zufällig; ein
* gehaltener Wert macht die Zusage explizit.
*/
function einstellung<T>(
schluessel: string,
ausAblage: (roh: string | null) => T,
inAblage: (wert: T) => string,
) {
let wert = ausAblage(ablageLesen(schluessel))
const horcher = new Set<() => void>()
return {
stand: () => wert,
horchen: (bei: () => void) => {
horcher.add(bei)
return () => horcher.delete(bei)
},
setzen: (neu: T) => {
if (neu === wert) return
wert = neu
ablageSchreiben(schluessel, inAblage(neu))
for (const bei of horcher) bei()
},
}
}
const THEMA = einstellung<Theme>(
"dm360.theme",
(roh) => {
if (roh === "nacht" || roh === "tag") return roh
// Ohne gespeicherte Wahl entscheidet das System; ohne beides: nacht.
const hell =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-color-scheme: light)").matches
return hell ? "tag" : "nacht"
},
(wert) => wert,
)
const BODEN = einstellung<boolean>(
"dm360.boden",
(roh) => roh !== "aus",
(an) => (an ? "an" : "aus"),
)
const TABBESCHRIFTUNG = einstellung<boolean>(
"dm360.tabbeschriftung",
(roh) => roh !== "aus",
(an) => (an ? "an" : "aus"),
)
/** Nur für Tests: setzt den gehaltenen Wert auf den Stand der Ablage zurück. */
export function themeStartwert(): Theme {
const gespeichert = gespeichertLesen()
if (gespeichert) return gespeichert
const hell =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-color-scheme: light)").matches
return hell ? "tag" : "nacht"
return THEMA.stand()
}
export function useTheme(): [Theme, (theme: Theme) => void] {
const [theme, setzeTheme] = useState<Theme>(themeStartwert)
const setzen = useCallback((neu: Theme) => {
setzeTheme(neu)
gespeichertSchreiben(neu)
}, [])
const theme = useSyncExternalStore(THEMA.horchen, THEMA.stand, THEMA.stand)
const setzen = useCallback((neu: Theme) => THEMA.setzen(neu), [])
// Farbe der Systemleisten mitziehen, damit auf dem Handy kein heller
// Balken über einer dunklen App steht.
// Balken über einer dunklen App steht. Werte des Panels (--canvas in
// audi-dashboard-ios.css); hier standen einmal #161b23/#ffffff aus dem
// Basis-Stylesheet, das im Panel nie greift — die Systemleiste hatte damit
// einen anderen Ton als die App darunter.
useEffect(() => {
const marke = document.querySelector('meta[name="theme-color"]')
// Werte des Panels (--canvas in audi-dashboard-ios.css). Hier standen noch
// #161b23/#ffffff aus dem Basis-Stylesheet, das im Panel nie greift —
// die Systemleiste hatte damit einen anderen Ton als die App darunter.
if (marke) marke.setAttribute("content", theme === "nacht" ? "#0C1014" : "#F2F2F7")
}, [theme])
@@ -62,52 +132,21 @@ export function useTheme(): [Theme, (theme: Theme) => void] {
/**
* Bodenschatten unter dem Fahrzeugbild — im Panel der Schalter „Boden" in den
* Einstellungen (`bodenAn`). Dort lebt er nur im Speicher und ist nach einem
* Neuladen wieder an; hier folgt er dem Muster der übrigen Ansichtsschalter
* und bleibt erhalten, Standard wie im Panel: an.
* Neuladen wieder an; hier bleibt er erhalten, Standard wie im Panel: an.
*/
const BODEN_SCHLUESSEL = "dm360.boden"
export function useBoden(): [boolean, (an: boolean) => void] {
const [an, setzeAn] = useState<boolean>(() => {
try {
return localStorage.getItem(BODEN_SCHLUESSEL) !== "aus"
} catch {
return true
}
})
const setzen = useCallback((neu: boolean) => {
setzeAn(neu)
try {
localStorage.setItem(BODEN_SCHLUESSEL, neu ? "an" : "aus")
} catch {
// Privatmodus
}
}, [])
const an = useSyncExternalStore(BODEN.horchen, BODEN.stand, BODEN.stand)
const setzen = useCallback((neu: boolean) => BODEN.setzen(neu), [])
return [an, setzen]
}
/** Sichtbarkeit der Tab-Beschriftungen, ebenfalls dauerhaft gespeichert. */
const BESCHRIFTUNG_SCHLUESSEL = "dm360.tabbeschriftung"
export function useTabBeschriftung(): [boolean, (an: boolean) => void] {
const [an, setzeAn] = useState<boolean>(() => {
try {
return localStorage.getItem(BESCHRIFTUNG_SCHLUESSEL) !== "aus"
} catch {
return true
}
})
const setzen = useCallback((neu: boolean) => {
setzeAn(neu)
try {
localStorage.setItem(BESCHRIFTUNG_SCHLUESSEL, neu ? "an" : "aus")
} catch {
// Privatmodus
}
}, [])
const an = useSyncExternalStore(
TABBESCHRIFTUNG.horchen,
TABBESCHRIFTUNG.stand,
TABBESCHRIFTUNG.stand,
)
const setzen = useCallback((neu: boolean) => TABBESCHRIFTUNG.setzen(neu), [])
return [an, setzen]
}