Compare commits

7 Commits
Author SHA1 Message Date
fototeddyandClaude Sonnet 5 2638438494 Add landscape layouts for level display, vehicle view, and alignment assistant
Rotating the phone into landscape while leveling used to leave a
portrait-shaped column of controls that needed scrolling to take in.
All three level-integration screens now branch on verticalSizeClass:
the bubble/vehicle status in the device screen and the full alignment
assistant (advice, readings, and wedge heights) lay out side-by-side
instead of stacked, sized to fit without scrolling.

The device screen also measures its actual available height via a
GeometryReader and feeds it to the level display so the bubble/vehicle
graphic scales to fill the landscape screen rather than being capped at
a fixed size, and the connection-status section moves to the bottom of
the list in landscape so it doesn't compete with the display for space.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:33:35 +02:00
fototeddyandClaude Sonnet 5 0fdef337fc Simplify CarPlay Live Activity to letter+arrow glance indicator
Replaces the full sentence ("Heck steht höher, links steht höher") in
the .small activity family with a compact "H ↑ L" style readout: a
direction letter per axis (only shown when outside tolerance) around a
single up arrow, since only the higher side is ever named anyway. Text
now shares the same green/orange/red scale as the bubble glyph (factored
into one levelColour helper instead of two duplicated thresholds), and
uses a scalable font style with minimumScaleFactor so it fits whatever
size CarPlay actually grants the card - there's no API to query the
physical display size from Live Activity content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:08:40 +02:00
fototeddyandClaude Sonnet 5 1b06ac36d1 Debounce Live Activity ending on BLE disconnect, auto-resume on reconnect
A dropped BLE connection ended the Live Activity immediately, which
flickered off and on during the brief, routine disconnects that happen
constantly while driving. It now waits 12s before actually ending, and
if the sensor reconnects within that window the pending end is simply
cancelled. If the disconnect does outlast the grace period and the
activity really ends, reconnecting afterwards restarts it automatically
with the last known reading, since that wasn't a deliberate user stop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 12:00:49 +02:00
fototeddyandClaude Sonnet 5 46a4da5e37 Fix Live Activity in CarPlay: show real bubble and text via .small family
CarPlay renders Live Activities through the .small activity family
(shared with the Watch Smart Stack), not the lock-screen content view.
Without it, CarPlay fell back to the Dynamic Island's compactLeading/
compactTrailing, which only had the generic "level" SF Symbol and an
empty trailing view — hence the wrong icon and missing text.

Adds .supplementalActivityFamilies([.small]) and a compact layout that
reuses the real LevelBubbleGlyph plus a one-line instruction for that
family, while the lock screen keeps its existing two-line view.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:37:50 +02:00
fototeddyandClaude Sonnet 5 45aa7cf984 Add auswählbare Fahrzeuggrafik am Neigungsmesser, neues California-Set
Jedes Grafikset (Seiten-/Heckansicht) ist jetzt ein VehicleGraphicStyle
mit eigenem Seitenverhältnis, wählbar in der Neigungsmesser-Einrichtung
und angewendet in Fahrzeug-Ansicht und Ausrichtungs-Assistent. Die
bisherigen VehicleSide/VehicleRear-Assets heißen jetzt VansterSide/
VansterRear, damit sie eindeutig ihrem Set zuzuordnen sind; California
kommt als zweites Set mit eigener Seitenansicht dazu (Heckansicht
vorerst von Vanster übernommen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:06:35 +02:00
fototeddyandClaude Sonnet 5 f175372318 Vereinfache Fahrzeug-Symbolauswahl auf sechs Icons, Default suv.side
Ersetzt die zehn wählbaren Symbole durch eine kürzere, konsistentere
Auswahl (car, car.side, suv.side, truck.pickup.side, box.truck, bus)
und setzt suv.side als neuen Standard für neu angelegte Fahrzeuge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 10:04:48 +02:00
fototeddyandClaude Sonnet 5 4a8a40eea2 Minimize Dynamic Island, show direction letters instead of signs, fix inverted pitch bubble
- Dynamic Island now shows only the level icon (compact/minimal); the
  bubble, instruction text, and numbers move to the shared lock
  screen/CarPlay ActivityView, which iOS renders identically on both
  surfaces (no separate CarPlay layout API exists for this).
- Replace +/- signs with a leading direction letter (H/F for Heck-
  Front, R/L for rechts-links) via a new shared LevelDirectionFormatting
  helper, used by the level screen, alignment assistant, and dashboard
  card. The generic "Messwerte" list keeps raw signed values.
- Live Activity's instruction now reads as two lines ("Heck steht
  höher 1.8°" / "links steht höher 0.9°") instead of a sentence plus a
  separate number column.
- Fix the bubble level graphic's pitch axis being inverted (front/rear
  reversed) in all three copies (app, watch, Live Activity widget) —
  confirmed against the known-correct VehicleTiltView orientation.
- Dashboard card shows pitch and roll side by side, large, without the
  "Längsneigung"/"Querneigung" labels, for the leveling device.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:03:37 +02:00
27 changed files with 746 additions and 276 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

@@ -0,0 +1,10 @@
{
"images" : [
{
"filename" : "CaliforniaSide.png",
"idiom" : "universal"
}
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "template-rendering-intent" : "template" }
}
@@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "VehicleRear.png",
"filename" : "VansterRear.png",
"idiom" : "universal"
}
],

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

@@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "VehicleSide.png",
"filename" : "VansterSide.png",
"idiom" : "universal"
}
],

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 253 KiB

+18 -2
View File
@@ -897,7 +897,17 @@ extension BluetoothManager: CBCentralManagerDelegate {
queue: queue,
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
onStateChange: { [weak self] state in
self?.publish { self?.linkStates[device.id] = state }
self?.publish {
self?.linkStates[device.id] = state
if state == .live {
// Verbindung wieder da: ein anstehendes Ende
// verwerfen oder eine zwischenzeitlich doch schon
// beendete Live Activity wieder aufnehmen.
self?.activityManager?.handleReconnect(
deviceID: device.id,
state: self?.levelStates[device.id] ?? LevelState())
}
}
},
onLevelState: { [weak self] state in
self?.publish {
@@ -970,7 +980,13 @@ extension BluetoothManager: CBCentralManagerDelegate {
.map { Date().timeIntervalSince($0) } ?? 0
if let device = managed[peripheral.identifier] {
publish { self.linkStates[device.id] = .searching }
let deviceName = store.devices.first { $0.id == device.id }?.name ?? ""
publish {
self.linkStates[device.id] = .searching
// Erst nach einer Gnadenfrist wirklich beenden sonst
// flackerte die Live Activity bei jedem kurzen Funkloch.
self.activityManager?.handleDisconnect(deviceID: device.id, deviceName: deviceName)
}
if lifetime >= stableConnection {
// Die Verbindung stand und ist weggefallen - im Fahrzeug der
// Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das
@@ -5,14 +5,28 @@ import Observation
/// Startet, aktualisiert und beendet die Live Activity des Neigungsmessers.
///
/// Es läuft höchstens eine Aktivität gleichzeitig mehr als einen
/// Neigungsmesser gibt es im aktiven Profil ohnehin nicht. Seit iOS 17 zeigt
/// Neigungsmesser gibt es im aktiven Profil ohnehin nicht. Seit iOS 26 zeigt
/// CarPlay eine laufende Live Activity automatisch im Dashboard an; ein
/// eigenes CarPlay-App-Target braucht es dafür nicht.
/// eigenes CarPlay-App-Target braucht es dafür nicht die Inhalte kommen
/// aber nicht von der Sperrbildschirm-Ansicht, sondern von der
/// `.small`-Aktivitätsfamilie (siehe `VanAligneiOSWidgetLiveActivity`).
@Observable
final class LevelActivityManager {
private(set) var trackedDeviceID: UUID?
@ObservationIgnored private var activity: Activity<LevelActivityAttributes>?
/// Wie lange nach einem Verbindungsabbruch gewartet wird, bevor die
/// Aktivität wirklich endet. Verbindungen fallen im Fahrzeug regelmässig
/// kurz weg ohne diese Gnadenfrist flackerte die Anzeige bei jedem
/// kurzen Funkloch aus und wieder ein.
static let disconnectGrace: TimeInterval = 12
@ObservationIgnored private var pendingEnd: Task<Void, Never>?
/// Gerät, dessen Aktivität wegen einer länger anhaltenden Trennung
/// tatsächlich beendet wurde bei der nächsten Wiederverbindung wird sie
/// automatisch neu gestartet, damit das kein bewusster Nutzer-Stop war.
@ObservationIgnored private var deviceToResume: (id: UUID, name: String)?
/// Ob gerade irgendeine Aktivität läuft die App muss dafür im
/// Hintergrund weiter nach dem Neigungsmesser funken, sonst friert die
/// Anzeige beim ersten Sperren des Bildschirms ein.
@@ -23,8 +37,11 @@ final class LevelActivityManager {
}
func start(deviceID: UUID, deviceName: String, state: LevelState) {
pendingEnd?.cancel()
pendingEnd = nil
deviceToResume = nil
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
end()
endActivity()
let attributes = LevelActivityAttributes(deviceName: deviceName)
let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil)
do {
@@ -44,7 +61,45 @@ final class LevelActivityManager {
Task { await activity.update(content) }
}
/// Bewusstes Beenden durch den Nutzer anders als bei einem
/// Verbindungsabbruch soll das bei der nächsten Verbindung nicht
/// automatisch wieder aufleben.
func end() {
pendingEnd?.cancel()
pendingEnd = nil
deviceToResume = nil
endActivity()
}
/// BLE-Verbindung weg: nicht sofort beenden, sondern erst nach einer
/// kurzen Gnadenfrist meldet sich das Gerät vorher zurück
/// (`handleReconnect`), passiert gar nichts.
func handleDisconnect(deviceID: UUID, deviceName: String) {
guard trackedDeviceID == deviceID, activity != nil, pendingEnd == nil else { return }
pendingEnd = Task { [weak self] in
try? await Task.sleep(for: .seconds(Self.disconnectGrace))
guard let self, !Task.isCancelled, self.trackedDeviceID == deviceID else { return }
self.deviceToResume = (deviceID, deviceName)
self.endActivity()
self.pendingEnd = nil
}
}
/// BLE-Verbindung wieder da: ein noch anstehendes Ende verwerfen, oder
/// falls die Gnadenfrist schon abgelaufen und die Aktivität wirklich
/// beendet war sie mit dem letzten bekannten Stand neu starten.
func handleReconnect(deviceID: UUID, state: LevelState) {
if pendingEnd != nil, trackedDeviceID == deviceID {
pendingEnd?.cancel()
pendingEnd = nil
return
}
guard let resume = deviceToResume, resume.id == deviceID else { return }
deviceToResume = nil
start(deviceID: deviceID, deviceName: resume.name, state: state)
}
private func endActivity() {
guard let activity else { return }
trackedDeviceID = nil
self.activity = nil
+128 -58
View File
@@ -11,6 +11,7 @@ struct AlignmentAssistantView: View {
@Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var assistant = AlignmentAssistant()
@State private var didAnnounceTarget = false
@@ -18,49 +19,19 @@ struct AlignmentAssistantView: View {
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
/// iPhone im Querformat meldet eine kompakte Höhe das ist das
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
private var isLandscape: Bool { verticalSizeClass == .compact }
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
Picker("Darstellung", selection: $displayStyle) {
ForEach(LevelDisplayStyle.allCases) { style in
Text(style.title).tag(style)
Group {
if isLandscape {
landscapeLayout
} else {
portraitLayout
}
}
.pickerStyle(.segmented)
.padding(.top, 8)
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxWidth: 320)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll)
}
if !isLive { disconnectedBanner }
adviceBanner
readings
if let best = assistant.best, let gain = assistant.improvementAtBest,
let seconds = assistant.timeSinceBest {
bestPointCard(best: best, gain: gain, seconds: seconds)
}
wedgeSection
Button("Neu beginnen", systemImage: "arrow.counterclockwise") {
assistant.reset()
didAnnounceTarget = false
}
.buttonStyle(.bordered)
.padding(.bottom, 24)
}
.padding(.horizontal)
.frame(maxWidth: .infinity)
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Ausrichtungs-Assistent")
.navigationBarTitleDisplayMode(.inline)
@@ -95,10 +66,94 @@ struct AlignmentAssistantView: View {
}
}
// MARK: - Layouts
private var portraitLayout: some View {
ScrollView {
VStack(spacing: 24) {
picker.padding(.top, 8)
display
if !isLive { disconnectedBanner }
adviceBanner
readings
if let best = assistant.best, let gain = assistant.improvementAtBest,
let seconds = assistant.timeSinceBest {
bestPointCard(best: best, gain: gain, seconds: seconds)
}
wedgeSection
resetButton
.padding(.bottom, 24)
}
.padding(.horizontal)
.frame(maxWidth: .infinity)
}
}
/// Anzeige links, alles zum Rangieren Nötige rechts ohne Scrollen, denn
/// im Querformat schaut man beiläufig hin, nicht in Ruhe. Die
/// Bestpunkt-Karte und der ausführliche Verbindungs-Hinweis bleiben dafür
/// dem Hochformat vorbehalten; die Keilhöhen bleiben in jedem Fall sichtbar.
private var landscapeLayout: some View {
HStack(alignment: .top, spacing: 16) {
VStack(spacing: 8) {
picker
display
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity)
VStack(spacing: 8) {
if !isLive { compactDisconnectedBanner }
adviceBanner
readings
wedgeSection
resetButton
}
.frame(maxWidth: .infinity)
}
.padding(12)
}
// MARK: - Bausteine
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
private var picker: some View {
Picker("Darstellung", selection: $displayStyle) {
ForEach(LevelDisplayStyle.allCases) { style in
Text(style.title).tag(style)
}
}
.pickerStyle(.segmented)
}
@ViewBuilder
private var display: some View {
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxWidth: isLandscape ? 220 : 320, maxHeight: isLandscape ? 160 : .infinity)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll,
style: device.vehicleGraphicStyle, compact: isLandscape)
}
}
private var resetButton: some View {
Button("Neu beginnen", systemImage: "arrow.counterclockwise") {
assistant.reset()
didAnnounceTarget = false
}
.buttonStyle(.bordered)
}
/// Reisst die Verbindung beim Rangieren ab, stehen die Zahlen still. Ohne
/// Hinweis sähe das aus, als hinge die App man rangiert dann nach einem
/// Wert, der längst nicht mehr gilt.
@@ -120,40 +175,54 @@ struct AlignmentAssistantView: View {
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 16))
}
/// Kurzform für Querformat: derselbe Hinweis in einer Zeile.
private var compactDisconnectedBanner: some View {
Label("Nicht verbunden Anzeige steht still", systemImage: "antenna.radiowaves.left.and.right.slash")
.font(.caption.weight(.medium))
.foregroundStyle(.orange)
.lineLimit(1)
.minimumScaleFactor(0.8)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 10))
}
private var adviceBanner: some View {
HStack(spacing: 12) {
Image(systemName: assistant.hasReachedTarget
? "checkmark.circle.fill" : assistant.trend.symbol)
.font(.title)
.font(isLandscape ? .title2 : .title)
.foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor)
Text(assistant.advice)
.font(.title3.weight(.medium))
.font(isLandscape ? .subheadline.weight(.medium) : .title3.weight(.medium))
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding()
.padding(isLandscape ? 10 : 16)
.background(assistant.hasReachedTarget ? Color.green.opacity(0.15)
: Color(.secondarySystemGroupedBackground),
in: .rect(cornerRadius: 16))
}
private var readings: some View {
HStack(spacing: 12) {
reading("Längs", state.pitch)
reading("Quer", state.roll)
reading("Gesamt", assistant.current?.deviation)
HStack(spacing: isLandscape ? 8 : 12) {
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
if !isLandscape {
reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation))
}
}
}
private func reading(_ title: String, _ value: Double?) -> some View {
VStack(spacing: 4) {
Text(value.map { String(format: "%.1f°", $0) } ?? "")
.font(.title2.weight(.semibold).monospacedDigit())
private func reading(_ title: String, _ text: String) -> some View {
VStack(spacing: isLandscape ? 1 : 4) {
Text(text)
.font((isLandscape ? Font.callout : .title2).weight(.semibold).monospacedDigit())
Text(title)
.font(.caption)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.padding(.vertical, isLandscape ? 6 : 12)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
}
@@ -187,23 +256,25 @@ struct AlignmentAssistantView: View {
trackWidth: width, wheelbase: base)
}()
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: isLandscape ? 6 : 10) {
Label("Auffahrkeile", systemImage: "triangle.fill")
.font(.headline)
.font(isLandscape ? .subheadline.weight(.semibold) : .headline)
if profile?.trackWidth == nil || profile?.wheelbase == nil {
Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt "
+ "sich beim Fahrzeug hinterlegen.")
.font(.callout)
.font(.caption)
.foregroundStyle(.secondary)
} else if let lift, !lift.isNegligible {
WheelLiftPlan(lift: lift)
WheelLiftPlan(lift: lift, isCompact: isLandscape)
.frame(maxWidth: .infinity)
if !isLandscape {
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
Text("Keine Keile nötig.")
.font(.callout)
@@ -211,7 +282,7 @@ struct AlignmentAssistantView: View {
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.padding(isLandscape ? 10 : 16)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
}
@@ -229,4 +300,3 @@ struct AlignmentAssistantView: View {
#endif
}
}
+17
View File
@@ -13,6 +13,8 @@ struct DeviceCard: View {
if isShowingLastSettings, let snapshot {
lastSettings(for: snapshot)
} else if let snapshot, snapshot.primaryMetric != nil, device.role == .leveling {
levelReadout(for: snapshot)
} else if let snapshot, let primary = snapshot.primaryMetric {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(primary.formatted)
@@ -115,6 +117,21 @@ struct DeviceCard: View {
}
}
/// Für die Nivellierung: Front/Heck und links/rechts nebeneinander, groß,
/// ohne "Längsneigung"/"Querneigung"-Beschriftung die Richtung steht ja
/// schon im vorangestellten Buchstaben.
private func levelReadout(for snapshot: DeviceSnapshot) -> some View {
let pitch = snapshot.metrics.first(where: { $0.key == "pitch" })?.value
let roll = snapshot.metrics.first(where: { $0.key == "roll" })?.value
return HStack(spacing: 24) {
Text(LevelDirectionFormatting.pitchTile(pitch))
Text(LevelDirectionFormatting.rollTile(roll))
}
.font(.system(size: 36, weight: .semibold, design: .rounded))
.contentTransition(.numericText())
.foregroundStyle(snapshot.isStale ? .secondary : .primary)
}
private func secondaryValues(for snapshot: DeviceSnapshot) -> some View {
let others = snapshot.metrics
.filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil }
+18 -2
View File
@@ -7,6 +7,7 @@ struct DeviceDetailView: View {
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var editedName = ""
/// Nur zum Vergleich mit dem, was das Gerät sendet. Eingetragen wird der
@@ -35,13 +36,24 @@ struct DeviceDetailView: View {
store.devices.first { $0.id == device.id } ?? device
}
/// iPhone im Querformat meldet eine kompakte Höhe das ist das
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
private var isLandscape: Bool { verticalSizeClass == .compact }
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] }
var body: some View {
// Die Höhe der Libelle/Fahrzeug-Anzeige im Querformat richtet sich
// nach der tatsächlich verfügbaren Bildschirmhöhe, nicht nach einem
// festen Wert deshalb misst ein GeometryReader die Liste von aussen.
GeometryReader { geometry in
List {
statusSection
// Im Querformat soll die Libelle/Fahrzeug-Ansicht sofort
// sichtbar sein, ohne erst am Verbindungsstatus
// vorbeizuscrollen der rutscht dort ganz ans Ende.
if !isLandscape { statusSection }
// if needsKeyAttention { keyPrompt }
// if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
@@ -50,7 +62,8 @@ struct DeviceDetailView: View {
if currentDevice.role == .leveling {
LevelControls(device: currentDevice,
state: bluetooth.levelStates[device.id] ?? LevelState())
state: bluetooth.levelStates[device.id] ?? LevelState(),
availableHeight: geometry.size.height)
}
if let snapshot, !snapshot.metrics.isEmpty {
@@ -94,6 +107,8 @@ struct DeviceDetailView: View {
}
}
if isLandscape { statusSection }
/* if let snapshot, snapshot.temperatures.count > 1 {
Section("Temperaturen") {
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
@@ -137,6 +152,7 @@ struct DeviceDetailView: View {
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
}
}
}
// MARK: - Abschnitte
+40
View File
@@ -25,6 +25,8 @@ struct LevelSetupView: View {
store.devices.first { $0.id == device.id } ?? device
}
private let graphicColumns = [GridItem(.adaptive(minimum: 76), spacing: 12)]
var body: some View {
List {
Section {
@@ -80,6 +82,44 @@ struct LevelSetupView: View {
} footer: {
Text(calibrationHint)
}
Section {
LazyVGrid(columns: graphicColumns, spacing: 12) {
ForEach(VehicleGraphicStyle.allCases) { style in
Button {
var updated = currentDevice
updated.vehicleGraphicStyle = style
store.update(updated)
} label: {
VStack(spacing: 4) {
Image(style.sideImageName)
.resizable()
.scaledToFit()
.padding(10)
.frame(width: 60, height: 60)
.background(currentDevice.vehicleGraphicStyle == style
? Color.accentColor.opacity(0.18)
: Color.secondary.opacity(0.08),
in: .rect(cornerRadius: 12))
.foregroundStyle(currentDevice.vehicleGraphicStyle == style
? Color.accentColor : Color.primary)
Text(style.title)
.font(.caption2)
.foregroundStyle(currentDevice.vehicleGraphicStyle == style
? Color.accentColor : Color.secondary)
}
}
.buttonStyle(.plain)
.accessibilityLabel(style.title)
}
}
.padding(.vertical, 4)
} header: {
Text("Fahrzeuggrafik")
} footer: {
Text("Wird in der Fahrzeug-Darstellung des Neigungsmessers verwendet. "
+ "Weitere Grafiken folgen.")
}
}
.navigationTitle("Neigungsmesser einrichten")
.navigationBarTitleDisplayMode(.inline)
+72 -19
View File
@@ -76,8 +76,9 @@ struct LevelBubble: View {
.fill(bubbleColor)
.frame(width: bubble, height: bubble)
// Positiver Pitch heisst: das Heck steht höher, die Blase
// wandert also nach oben in der Ansicht nach hinten.
.offset(x: offsetX, y: -offsetY)
// wandert also nach unten in der Ansicht nach hinten.
// Muss zur "Fahrzeug"-Ansicht (VehicleTiltView) passen.
.offset(x: offsetX, y: offsetY)
.animation(.spring(duration: 0.35), value: offsetX)
.animation(.spring(duration: 0.35), value: offsetY)
.opacity(pitch == nil && roll == nil ? 0.25 : 1)
@@ -104,30 +105,59 @@ struct LevelBubble: View {
struct LevelControls: View {
let device: ConfiguredDevice
let state: LevelState
/// Höhe der umgebenden Liste, von `DeviceDetailView` per `GeometryReader`
/// gemessen. Nur im Querformat gebraucht, um die Anzeige auf
/// Bildschirmhöhe zu bringen statt sie auf einen festen Wert zu kappen.
var availableHeight: CGFloat?
@Environment(BluetoothManager.self) private var bluetooth
@Environment(DeviceStore.self) private var store
@Environment(LevelActivityManager.self) private var levelActivity
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var showAssistant = false
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
/// iPhone im Querformat meldet eine kompakte Höhe das ist das
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
private var isLandscape: Bool { verticalSizeClass == .compact }
/// Verfügbare Höhe abzüglich grober Reserve für Listenränder und die
/// eigene vertikale Auffüllung genug, um praktisch den ganzen
/// Bildschirm zu nutzen, ohne über den unteren Rand hinauszuschiessen.
private var landscapeContentHeight: CGFloat {
max(150, (availableHeight ?? 350) - 56)
}
var body: some View {
Section {
VStack(spacing: 16) {
Picker("Darstellung", selection: $displayStyle) {
ForEach(LevelDisplayStyle.allCases) { style in
Text(style.title).tag(style)
}
}
.pickerStyle(.segmented)
if isLandscape {
HStack(alignment: .center, spacing: 20) {
display
.frame(maxWidth: .infinity)
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxHeight: 220)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll)
VStack(spacing: 12) {
picker
if let instruction = state.instruction {
Label(instruction,
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
.font(.subheadline.weight(.semibold))
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
.multilineTextAlignment(.center)
}
HStack(spacing: 16) {
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
}
}
.frame(maxWidth: .infinity)
}
.frame(height: landscapeContentHeight)
.padding(.vertical, 4)
} else {
VStack(spacing: 16) {
picker
display
if let instruction = state.instruction {
Label(instruction,
@@ -138,13 +168,14 @@ struct LevelControls: View {
}
HStack(spacing: 24) {
reading("Längs", state.pitch)
reading("Quer", state.roll)
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
}
Section {
Button {
@@ -218,6 +249,28 @@ struct LevelControls: View {
}
}
private var picker: some View {
Picker("Darstellung", selection: $displayStyle) {
ForEach(LevelDisplayStyle.allCases) { style in
Text(style.title).tag(style)
}
}
.pickerStyle(.segmented)
}
@ViewBuilder
private var display: some View {
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxHeight: isLandscape ? landscapeContentHeight : 220)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll,
style: device.vehicleGraphicStyle, compact: isLandscape,
compactPanelHeight: max(60, landscapeContentHeight - 40))
}
}
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
private var liveActivityBinding: Binding<Bool> {
@@ -239,9 +292,9 @@ struct LevelControls: View {
state.isKnownUncalibrated ? "Nullpunkt fehlt" : device.sensorOrientation.summary
}
private func reading(_ title: String, _ value: Double?) -> some View {
private func reading(_ title: String, _ text: String) -> some View {
VStack(spacing: 2) {
Text(value.map { String(format: "%.1f°", $0) } ?? "")
Text(text)
.font(.title2.weight(.semibold).monospacedDigit())
Text(title)
.font(.caption)
+1 -1
View File
@@ -145,7 +145,7 @@ private struct ProfileEditView: View {
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var symbol = "box.truck"
@State private var symbol = "suv.side"
@State private var trackWidth = ""
@State private var wheelbase = ""
+26 -18
View File
@@ -15,22 +15,29 @@ import SwiftUI
struct VehicleTiltView: View {
let pitch: Double?
let roll: Double?
var style: VehicleGraphicStyle = .vanster
/// Für Querformat: beide Ansichten nebeneinander statt untereinander,
/// kleinere Schrift, ohne Überhöhungs-Hinweis muss ohne Scrollen in die
/// Bildschirmhöhe passen.
var compact = false
/// Bildhöhe je Panel im Querformat vom Aufrufer an die tatsächlich
/// verfügbare Bildschirmhöhe angepasst, statt fest verdrahtet.
var compactPanelHeight: CGFloat = 62
var body: some View {
if compact {
HStack(alignment: .top, spacing: 16) {
tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
}
} else {
VStack(spacing: 20) {
tiltPanel(image: "VehicleSide",
angle: pitch,
title: "Längs",
lowerLabel: "Front",
upperLabel: "Heck",
aspect: VehicleTilt.sideAspect)
tiltPanel(image: "VehicleRear",
angle: roll,
title: "Quer",
lowerLabel: "links",
upperLabel: "rechts",
aspect: VehicleTilt.rearAspect)
tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
Text(String(format: "Neigung %.0f-fach überhöht dargestellt "
@@ -42,6 +49,7 @@ struct VehicleTiltView: View {
.frame(maxWidth: .infinity)
}
}
}
private func tiltPanel(image: String,
angle: Double?,
@@ -49,13 +57,13 @@ struct VehicleTiltView: View {
lowerLabel: String,
upperLabel: String,
aspect: Double) -> some View {
VStack(spacing: 8) {
VStack(spacing: compact ? 4 : 8) {
HStack {
Text(title)
.font(.subheadline.weight(.medium))
.font(compact ? .caption2.weight(.medium) : .subheadline.weight(.medium))
Spacer()
Text(angle.map { String(format: "%.1f°", $0) } ?? "")
.font(.subheadline.weight(.semibold).monospacedDigit())
.font((compact ? Font.caption2 : .subheadline).weight(.semibold).monospacedDigit())
.foregroundStyle(VehicleTilt.colour(for: angle))
}
@@ -75,14 +83,14 @@ struct VehicleTiltView: View {
.animation(.spring(duration: 0.4), value: angle)
.opacity(angle == nil ? 0.3 : 1)
}
.frame(height: 110)
.frame(height: compact ? compactPanelHeight : 110)
HStack {
Text(lowerLabel)
Spacer()
Text(upperLabel)
}
.font(.caption2)
.font(compact ? .system(size: 9) : .caption2)
.foregroundStyle(.secondary)
}
}
@@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "VehicleRear.png",
"filename" : "VansterRear.png",
"idiom" : "universal"
}
],

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

@@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "VehicleSide.png",
"filename" : "VansterSide.png",
"idiom" : "universal"
}
],

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

+2 -2
View File
@@ -31,9 +31,9 @@ struct WatchBubble: View {
.fill(color)
.frame(width: bubble, height: bubble)
// Positiver Pitch heisst: das Heck steht höher, die Blase
// wandert nach oben in der Ansicht also nach hinten.
// wandert nach unten in der Ansicht also nach hinten.
.offset(x: clamped(roll) / range * travel,
y: -clamped(pitch) / range * travel)
y: clamped(pitch) / range * travel)
.animation(.spring(duration: 0.3), value: pitch)
.animation(.spring(duration: 0.3), value: roll)
.opacity(pitch == nil && roll == nil ? 0.25 : 1)
@@ -14,9 +14,9 @@ struct WatchVehicleView: View {
/// Die angeschriebenen Gradzahlen bleiben echt.
var body: some View {
VStack(spacing: 8) {
panel(image: "VehicleSide", angle: pitch, title: "Längs",
panel(image: "VansterSide", angle: pitch, title: "Längs",
lower: "Front", upper: "Heck", aspect: VehicleTilt.sideAspect)
panel(image: "VehicleRear", angle: roll, title: "Quer",
panel(image: "VansterRear", angle: roll, title: "Quer",
lower: "links", upper: "rechts", aspect: VehicleTilt.rearAspect)
}
}
+4
View File
@@ -105,6 +105,8 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
//var fridgeZoneMode: FridgeZoneMode = .automatic
/// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt.
var sensorOrientation = SensorOrientation.identity
/// Nur für den Neigungsmesser: welche Fahrzeuggrafik die Fahrzeug-Ansicht zeigt.
var vehicleGraphicStyle = VehicleGraphicStyle.vanster
init(id: UUID = UUID(),
name: String,
@@ -135,5 +137,7 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
// ?? .automatic
sensorOrientation = try container.decodeIfPresent(SensorOrientation.self,
forKey: .sensorOrientation) ?? .identity
vehicleGraphicStyle = try container.decodeIfPresent(VehicleGraphicStyle.self,
forKey: .vehicleGraphicStyle) ?? .vanster
}
}
+2 -3
View File
@@ -15,7 +15,7 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
init(id: UUID = UUID(),
name: String,
symbol: String = "box.truck",
symbol: String = "suv.side",
trackWidth: Double? = nil,
wheelbase: Double? = nil) {
self.id = id
@@ -47,7 +47,6 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
/// Alle Namen gegen NSImage(systemSymbolName:) geprüft ein nicht
/// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen.
static let symbols = [
"box.truck", "truck.pickup.side", "bus", "bus.doubledecker",
"car", "car.side", "tent", "sailboat", "house.lodge", "mountain.2",
"car", "car.side", "suv.side", "truck.pickup.side", "box.truck", "bus",
]
}
+52
View File
@@ -0,0 +1,52 @@
import Foundation
/// Welche Silhouette die Fahrzeug-Ansicht des Neigungsmessers zeigt.
///
/// Jeder Fall ist ein zusammengehöriges Bilderset aus Seiten- und
/// Heckansicht in Assets.xcassets, beide als Vorlage ("template")
/// exportiert, damit `foregroundStyle` sie einfärben kann. Im Picker wird
/// nur die Seitenansicht gezeigt.
enum VehicleGraphicStyle: String, CaseIterable, Identifiable, Codable, Sendable {
case vanster
case california
var id: String { rawValue }
var title: String {
switch self {
case .vanster: return "Vanster"
case .california: return "California"
}
}
var sideImageName: String {
switch self {
case .vanster: return "VansterSide"
case .california: return "CaliforniaSide"
}
}
/// Für California liegt noch keine eigene Heckansicht vor, deshalb
/// vorerst die von Vanster.
var rearImageName: String {
switch self {
case .vanster, .california: return "VansterRear"
}
}
/// Seitenverhältnis der jeweiligen Bilder ohne das würde eine Grafik
/// mit anderen Proportionen in der gemeinsamen Bildhülle verzerrt oder
/// ungewollt verkleinert erscheinen.
var sideAspect: Double {
switch self {
case .vanster: return VehicleTilt.sideAspect
case .california: return 1
}
}
var rearAspect: Double {
switch self {
case .vanster, .california: return VehicleTilt.rearAspect
}
}
}
@@ -0,0 +1,30 @@
import Foundation
/// Formatiert Neigungswerte mit vorangestelltem Richtungsbuchstaben statt
/// Vorzeichen "H 1.8°" statt "1.8°", "L 0.9°" statt "-0.9°".
///
/// Gemeinsam für App und Live-Activity-Widget, deshalb hier statt in
/// `Shared/`, das die Extension nicht mitkompiliert (siehe
/// `LevelActivityAttributes.swift`).
enum LevelDirectionFormatting {
/// "1.8°" Betrag ohne Vorzeichen, für Stellen, die die Richtung schon
/// im Wort oder Buchstaben ausdrücken.
static func magnitude(_ value: Double?) -> String {
guard let value else { return "" }
return String(format: "%.1f°", abs(value))
}
/// Längsneigung als Kachel: positiv (Heck höher) "H", negativ (Front
/// höher) "F". Muss zum Vorzeichen von `LevelState.pitch` passen.
static func pitchTile(_ value: Double?) -> String {
guard let value else { return "" }
return "\(value >= 0 ? "H" : "F") \(magnitude(value))"
}
/// Querneigung als Kachel: positiv (rechts höher) "R", negativ (links
/// höher) "L". Muss zum Vorzeichen von `LevelState.roll` passen.
static func rollTile(_ value: Double?) -> String {
guard let value else { return "" }
return "\(value >= 0 ? "R" : "L") \(magnitude(value))"
}
}
+7 -1
View File
@@ -32,5 +32,11 @@ gefüllte Form, in der die Details erhalten bleiben.
```bash
swiftc -O -o /tmp/make-vehicle-art Tools/make-vehicle-art.swift
sips --resampleHeight 600 heckansicht.png --out /tmp/rear.png
/tmp/make-vehicle-art /tmp/rear.png CamperMonitor/Assets.xcassets/VehicleRear.imageset/VehicleRear.png
/tmp/make-vehicle-art /tmp/rear.png CamperMonitor/Assets.xcassets/VansterRear.imageset/VansterRear.png
```
Jedes Fahrzeug-Grafikset (siehe `VehicleGraphicStyle`) hat sein eigenes
Bildpaar `<Name>Side`/`<Name>Rear`. Hat die Vorlage schon einen
transparenten Hintergrund, geht die Quelldatei direkt ins Skript. Liegt sie
auf weißem Grund, muss der Rand vorher weg sonst wird die ganze Fläche
mitgefärbt.
@@ -9,14 +9,6 @@ import ActivityKit
import WidgetKit
import SwiftUI
/// Formatiert Neigungswerte wie in der App °, eine Nachkommastelle, ""
/// wenn kein Messwert da ist. Eigene Kopie, da die Extension nicht von
/// `Shared/Models/Metric.swift` abhängt.
private func degreesText(_ value: Double?) -> String {
guard let value else { return "" }
return String(format: "%.1f°", value)
}
private struct LevelIcon: View {
let isLevel: Bool
let hasReading: Bool
@@ -27,6 +19,20 @@ private struct LevelIcon: View {
}
}
/// Muss mit `LevelState.levelTolerance` übereinstimmen.
private let levelTolerance: Double = 0.5
/// Grün bis Toleranz, orange bis 2°, sonst rot dieselbe Skala für Blase
/// und Text, wie in der App (`VehicleTilt.colour`). Hier dupliziert, weil
/// die Extension `Shared/` nicht mitkompiliert (siehe
/// `LevelActivityAttributes.swift`).
private func levelColour(forDeviation deviation: Double?) -> Color {
guard let deviation else { return .white.opacity(0.4) }
if deviation <= levelTolerance { return .green }
if deviation <= 2 { return .orange }
return .red
}
/// Verkleinerte Libelle für Sperrbildschirm/CarPlay und die erweiterte
/// Dynamic Island dieselbe Optik wie `LevelBubble` in der App, aber ohne
/// deren Abhängigkeit auf `Shared/Models/LevelState.swift`, das in der
@@ -39,17 +45,12 @@ private struct LevelBubbleGlyph: View {
/// Bis zu welcher Neigung die Blase ausschlägt, wie in der App-Libelle.
private let range: Double = 6
/// Muss mit `LevelState.levelTolerance` übereinstimmen.
private let tolerance: Double = 0.5
private var hasReading: Bool { pitch != nil || roll != nil }
private var bubbleColor: Color {
guard let pitch, let roll else { return .white.opacity(0.4) }
let deviation = max(abs(pitch), abs(roll))
if deviation <= tolerance { return .green }
if deviation <= 2 { return .orange }
return .red
return levelColour(forDeviation: max(abs(pitch), abs(roll)))
}
var body: some View {
@@ -58,7 +59,7 @@ private struct LevelBubbleGlyph: View {
let travel = radius - bubble / 2 - 3
let offsetX = clamped(roll) / range * travel
let offsetY = clamped(pitch) / range * travel
let toleranceRadius = max(tolerance / range * travel, bubble * 0.55)
let toleranceRadius = max(levelTolerance / range * travel, bubble * 0.55)
ZStack {
Circle()
@@ -73,8 +74,8 @@ private struct LevelBubbleGlyph: View {
.fill(bubbleColor)
.frame(width: bubble, height: bubble)
// Positiver Pitch heisst: Heck steht höher, die Blase wandert
// also nach oben wie bei der echten Wasserwaage in der App.
.offset(x: offsetX, y: -offsetY)
// also nach unten wie bei der echten Wasserwaage in der App.
.offset(x: offsetX, y: offsetY)
.opacity(hasReading ? 1 : 0.3)
}
.frame(width: diameter, height: diameter)
@@ -89,81 +90,174 @@ private struct LevelBubbleGlyph: View {
struct VanAligneiOSWidgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: LevelActivityAttributes.self) { context in
LockScreenLevelView(attributes: context.attributes, state: context.state)
LevelActivityContent(attributes: context.attributes, state: context.state)
.activityBackgroundTint(Color(white: 0.08))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
// Die Dynamic Island bleibt bewusst so klein wie möglich Libelle
// und Zahlenwerte gehören auf Sperrbildschirm/CarPlay, hier reicht
// ein Blick auf Icon und, aufgeklappt, die Kurzanweisung.
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
LevelBubbleGlyph(pitch: context.state.pitch, roll: context.state.roll,
isLevel: context.state.isLevel, diameter: 44)
}
DynamicIslandExpandedRegion(.trailing) {
VStack(alignment: .trailing, spacing: 2) {
Text(degreesText(context.state.pitch)).monospacedDigit()
Text(degreesText(context.state.roll)).monospacedDigit()
}
.font(.headline)
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
.font(.title3)
}
DynamicIslandExpandedRegion(.bottom) {
Text(context.state.instruction ?? context.attributes.deviceName)
.font(.subheadline)
.font(.caption)
.foregroundStyle(context.state.isLevel ? .green : .primary)
.lineLimit(1)
}
} compactLeading: {
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
} compactTrailing: {
Text(degreesText(context.state.pitch)).monospacedDigit()
EmptyView()
} minimal: {
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
}
.keylineTint(context.state.isLevel ? .green : .orange)
}
.supplementalActivityFamilies([.small])
}
}
/// Sperrbildschirm- und Banner-Ansicht. Dieselbe Ansicht landet automatisch
/// auch im CarPlay-Dashboard, sobald das iPhone verbunden ist ein eigenes
/// CarPlay-App-Target ist dafür nicht nötig.
/// Wählt je nach Darstellungsgrösse zwischen Sperrbildschirm- und
/// Kompaktansicht.
///
/// Ohne `.small`-Familie fällt CarPlay (wie die Smart Stack der Uhr) auf
/// `compactLeading`/`compactTrailing` der Dynamic Island zurück dort steht
/// nur das generische Symbol und, weil `compactTrailing` leer bleibt, gar
/// kein Text. Erst die `.small`-Familie liefert CarPlay Libelle und Text.
private struct LevelActivityContent: View {
let attributes: LevelActivityAttributes
let state: LevelActivityAttributes.ContentState
@Environment(\.activityFamily) private var activityFamily
var body: some View {
switch activityFamily {
case .small:
CompactLevelView(attributes: attributes, state: state)
default:
LockScreenLevelView(attributes: attributes, state: state)
}
}
}
/// Kompaktform für CarPlay-Dashboard und Smart Stack der Uhr: Libelle plus
/// ein Blick-Symbol statt eines ganzen Satzes zum Lesen im Vorbeifahren.
///
/// Gezeigt wird nur, welche Seite höher steht: Buchstabe (Längsneigung),
/// Pfeil, Buchstabe (Querneigung) z.B. "H L" für "Heck und links stehen
/// höher". Ein einzelner Pfeil in der Mitte reicht, weil ohnehin nur die
/// höher stehende Seite benannt wird ("höher" ist implizit "nach oben").
private struct CompactLevelView: View {
let attributes: LevelActivityAttributes
let state: LevelActivityAttributes.ContentState
private var hasReading: Bool { state.pitch != nil || state.roll != nil }
/// "H"/"F", nur wenn ausserhalb der Toleranz.
private var pitchLetter: String? {
guard let pitch = state.pitch, abs(pitch) > levelTolerance else { return nil }
return pitch >= 0 ? "H" : "F"
}
/// "R"/"L", nur wenn ausserhalb der Toleranz.
private var rollLetter: String? {
guard let roll = state.roll, abs(roll) > levelTolerance else { return nil }
return roll >= 0 ? "R" : "L"
}
/// Dieselbe Skala wie die Blase, über die grössere der beiden Neigungen.
private var statusColor: Color {
guard let pitch = state.pitch, let roll = state.roll else { return .white.opacity(0.4) }
return levelColour(forDeviation: max(abs(pitch), abs(roll)))
}
var body: some View {
HStack(spacing: 14) {
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 40)
Group {
if !hasReading {
Text("")
} else if state.isLevel {
Image(systemName: "checkmark.circle.fill")
} else {
HStack(spacing: 6) {
if let pitchLetter { Text(pitchLetter) }
Image(systemName: "arrow.up")
if let rollLetter { Text(rollLetter) }
}
}
}
// Skalierbarer Textstil statt fester Punktgrösse: CarPlay-
// Bildschirme unterscheiden sich in Grösse, die exakten Masse
// eines Displays sind aber nicht abfragbar. `minimumScaleFactor`
// lässt die Schrift so gross wie möglich, aber so klein wie
// nötig werden, um in den vom System zugeteilten Platz zu passen.
.font(.title2.weight(.bold))
.minimumScaleFactor(0.5)
.lineLimit(1)
.foregroundStyle(statusColor)
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
}
/// Sperrbildschirm- und Banner-Ansicht.
private struct LockScreenLevelView: View {
let attributes: LevelActivityAttributes
let state: LevelActivityAttributes.ContentState
var body: some View {
HStack(spacing: 16) {
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel)
/// Längszeile, z.B. "Heck steht höher 1.8°" nur wenn ausserhalb der
/// Toleranz, genau wie bei `LevelState.instruction` in der App.
private var pitchLine: String? {
guard let pitch = state.pitch, abs(pitch) > levelTolerance else { return nil }
let label = pitch >= 0 ? "Heck steht höher" : "Front steht höher"
return "\(label) \(LevelDirectionFormatting.magnitude(pitch))"
}
VStack(alignment: .leading, spacing: 4) {
/// Querzeile, z.B. "links steht höher 0.9°".
private var rollLine: String? {
guard let roll = state.roll, abs(roll) > levelTolerance else { return nil }
let label = roll >= 0 ? "rechts steht höher" : "links steht höher"
return "\(label) \(LevelDirectionFormatting.magnitude(roll))"
}
var body: some View {
HStack(spacing: 20) {
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 64)
VStack(alignment: .leading, spacing: 6) {
Text(attributes.deviceName)
.font(.caption)
.foregroundStyle(.secondary)
Text(state.instruction ?? "Keine Messwerte")
.font(.headline)
if pitchLine == nil && rollLine == nil {
Text(state.pitch == nil && state.roll == nil ? "Keine Messwerte" : "Steht eben")
.font(.title3.weight(.semibold))
.foregroundStyle(state.isLevel ? .green : .white)
.lineLimit(2)
} else {
if let pitchLine {
Text(pitchLine).font(.title3.weight(.semibold))
}
if let rollLine {
Text(rollLine).font(.title3.weight(.semibold))
}
}
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
reading("Längs", state.pitch)
reading("Quer", state.roll)
}
Spacer(minLength: 0)
}
.padding(16)
.foregroundStyle(.white)
}
private func reading(_ title: String, _ value: Double?) -> some View {
HStack(spacing: 6) {
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
Text(degreesText(value))
.font(.subheadline.monospacedDigit())
}
}
}
extension LevelActivityAttributes {