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:
+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