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 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 14:24:52 +02:00
parent 26f72930c3
commit b1411625aa
58 changed files with 2846 additions and 368 deletions
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Entitlements der Haupt-App. Bislang hatte sie keine eigene Datei - erst die
App-Gruppe der Share-Erweiterung macht eine noetig. Derselbe Bezeichner wie
in native/teilen/DataMetric360Share.entitlements.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.datametric360</string>
</array>
</dict>
</plist>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Die App-Gruppe ist der einzige gemeinsame Boden zwischen Erweiterung und
App - ohne sie kann die Erweiterung nichts uebergeben. Derselbe Bezeichner
muss in App.entitlements stehen und im Apple-Entwicklerportal als App Group
angelegt sein.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.datametric360</string>
</array>
</dict>
</plist>
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Info.plist der Share-Erweiterung.
NSExtensionActivationRule entscheidet, wann DataMetric360 im Teilen-Blatt
ueberhaupt auftaucht. Bewusst eng: genau EIN PDF, sonst nichts. Die
Erweiterung kann nur Tankbelege, und ein Eintrag, der bei jedem Foto und
jedem Link mitangeboten wird und dann ablehnt, ist schlechter als keiner.
NSExtensionPrincipalClass zeigt auf die Swift-Klasse ohne Storyboard -
die Erweiterung hat keine eigene Oberflaeche, sie nimmt die Datei entgegen
und oeffnet die App. Deshalb steht hier auch kein NSExtensionMainStoryboard.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>de</string>
<key>CFBundleDisplayName</key>
<string>DataMetric360</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsAttachmentsWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,157 @@
//
// ShareViewController.swift
// DataMetric360Share
//
// Nimmt ein PDF aus dem iOS-Teilen-Blatt entgegen (typischerweise ein
// Tankbeleg aus Mail) und reicht es an die App weiter.
//
// WIE DIE ÜBERGABE FUNKTIONIERT UND WARUM SO
// --------------------------------------------
// Eine Share-Erweiterung ist ein eigener Prozess mit eigenem Dateibereich.
// Sie kann der App nichts direkt geben; der einzige gemeinsame Boden ist
// eine App-Gruppe. Der Weg hier:
//
// 1. Das PDF wird gelesen und Base64-kodiert.
// 2. Es landet zusammen mit seinem Namen in den geteilten
// UserDefaults der Gruppe `group.app.datametric360`.
// 3. Die Erweiterung öffnet die App über ihr eigenes URL-Schema.
// 4. Die App liest den Eintrag beim Start bzw. beim Zurückkehren aus,
// löscht ihn und lädt den Beleg hoch (daten/geteilterBeleg.ts).
//
// Base64 in UserDefaults statt einer Datei im Gruppen-Container: die App
// liest UserDefaults ohnehin schon über @capacitor/preferences, das genau
// diese Gruppe kennt. Eine Datei im Container bräuchte dagegen eigenen
// nativen Code auf der App-Seite ein zusätzliches Plugin für einen Weg,
// den es sonst nicht gäbe.
//
// Der Preis ist eine Größengrenze: UserDefaults ist für Einstellungen
// gedacht, nicht für Anhänge. GRENZE_BYTES hält das bei 4 MB. Ein
// Tankbeleg liegt bei einigen Dutzend Kilobyte, ein gescanntes Mehrseiten-
// PDF kann darüber liegen dann sagt die Erweiterung das und der Weg über
// Datei auswählen" in der App bleibt.
//
// DIESE DATEI IST VERSIONIERT, ios/ NICHT.
// `npx cap add ios` erzeugt den nativen Ordner neu und würde alles darin
// verlieren. scripts/ios-teilen-einrichten.mjs kopiert diese Datei bei
// jedem Bau wieder hinein und hängt das Ziel ins Xcode-Projekt.
//
import UIKit
import Social
import UniformTypeIdentifiers
class ShareViewController: UIViewController {
/// Muss zur App-Gruppe in beiden Entitlements-Dateien passen.
private let gruppe = "group.app.datametric360"
/// Schlüssel, unter dem die App den Beleg abholt (geteilterBeleg.ts).
private let schluessel = "dm360_geteilter_beleg"
/// 4 MB. UserDefaults ist kein Dateispeicher darüber wird abgelehnt,
/// statt das System mit einem Anhang zu belasten, für den es nicht
/// gedacht ist.
private let grenzeBytes = 4 * 1024 * 1024
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
pdfUebernehmen()
}
private func pdfUebernehmen() {
guard
let eintrag = (extensionContext?.inputItems as? [NSExtensionItem])?.first,
let anhaenge = eintrag.attachments
else {
return abschliessen()
}
let pdfTyp = UTType.pdf.identifier
guard let anhang = anhaenge.first(where: { $0.hasItemConformingToTypeIdentifier(pdfTyp) })
else {
return melden("Kein PDF dabei.")
}
anhang.loadItem(forTypeIdentifier: pdfTyp, options: nil) { [weak self] wert, _ in
guard let self else { return }
// Je nach Quelle kommt eine URL, ein Data-Objekt oder nichts
// Brauchbares an Mail liefert eine URL auf eine temporäre
// Kopie, andere Apps reichen die Bytes direkt.
var daten: Data?
var name = "beleg.pdf"
if let url = wert as? URL {
daten = try? Data(contentsOf: url)
name = url.lastPathComponent
} else if let roh = wert as? Data {
daten = roh
}
guard let daten else {
return self.melden("Der Beleg ließ sich nicht lesen.")
}
guard daten.count <= self.grenzeBytes else {
return self.melden(
"Der Beleg ist zu groß für diesen Weg. In der App über „Beleg hochladen" Datei auswählen".")
}
self.ablegen(daten: daten, name: name)
}
}
private func ablegen(daten: Data, name: String) {
guard let ablage = UserDefaults(suiteName: gruppe) else {
return melden("Die App-Gruppe ist nicht eingerichtet.")
}
// Form wie in geteilterBeleg.ts erwartet. @capacitor/preferences legt
// Werte als String ab, deshalb JSON und kein Dictionary.
let inhalt: [String: Any] = [
"name": name,
"base64": daten.base64EncodedString(),
"zeit": Int(Date().timeIntervalSince1970 * 1000),
]
guard
let json = try? JSONSerialization.data(withJSONObject: inhalt),
let text = String(data: json, encoding: .utf8)
else {
return melden("Der Beleg ließ sich nicht übergeben.")
}
ablage.set(text, forKey: schluessel)
DispatchQueue.main.async { [weak self] in
self?.appOeffnen()
self?.abschliessen()
}
}
/// Öffnet die Host-App. Eine Erweiterung darf `UIApplication.shared` nicht
/// benutzen, deshalb der Umweg über die Responder-Kette der einzige von
/// Apple vorgesehene Weg aus einer Erweiterung heraus.
private func appOeffnen() {
guard let ziel = URL(string: "datametric360://beleg") else { return }
var antwort: UIResponder? = self
while let r = antwort {
if let app = r as? UIApplication {
app.open(ziel, options: [:], completionHandler: nil)
return
}
antwort = r.next
}
}
private func melden(_ text: String) {
DispatchQueue.main.async { [weak self] in
let hinweis = UIAlertController(title: "DataMetric360", message: text, preferredStyle: .alert)
hinweis.addAction(UIAlertAction(title: "Verstanden", style: .default) { _ in
self?.abschliessen()
})
self?.present(hinweis, animated: true)
}
}
private func abschliessen() {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}