From 26384384949efc3abe99ef17ee384c6e5cfdbde5 Mon Sep 17 00:00:00 2001 From: fototeddy Date: Sat, 5 Sep 2026 14:33:35 +0200 Subject: [PATCH] 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 --- .../Views/AlignmentAssistantView.swift | 191 ++++++++++++------ CamperMonitor/Views/DeviceDetailView.swift | 174 ++++++++-------- CamperMonitor/Views/LevelView.swift | 109 +++++++--- CamperMonitor/Views/VehicleTiltView.swift | 61 +++--- 4 files changed, 339 insertions(+), 196 deletions(-) diff --git a/CamperMonitor/Views/AlignmentAssistantView.swift b/CamperMonitor/Views/AlignmentAssistantView.swift index 44563fc..4c66857 100644 --- a/CamperMonitor/Views/AlignmentAssistantView.swift +++ b/CamperMonitor/Views/AlignmentAssistantView.swift @@ -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,18 @@ 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) - } - } - .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, - style: device.vehicleGraphicStyle) - } - - 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) + Group { + if isLandscape { + landscapeLayout + } else { + portraitLayout } - .padding(.horizontal) - .frame(maxWidth: .infinity) } .background(Color(.systemGroupedBackground)) .navigationTitle("Ausrichtungs-Assistent") @@ -96,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. @@ -121,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) { + HStack(spacing: isLandscape ? 8 : 12) { reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch)) reading("Quer", LevelDirectionFormatting.rollTile(state.roll)) - reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation)) + if !isLandscape { + reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation)) + } } } private func reading(_ title: String, _ text: String) -> some View { - VStack(spacing: 4) { + VStack(spacing: isLandscape ? 1 : 4) { Text(text) - .font(.title2.weight(.semibold).monospacedDigit()) + .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)) } @@ -188,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) - .foregroundStyle(.secondary) - } else if let lift, !lift.isNegligible { - WheelLiftPlan(lift: lift) - .frame(maxWidth: .infinity) - - 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 if let lift, !lift.isNegligible { + 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) @@ -212,7 +282,7 @@ struct AlignmentAssistantView: View { } } .frame(maxWidth: .infinity, alignment: .leading) - .padding() + .padding(isLandscape ? 10 : 16) .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16)) } @@ -230,4 +300,3 @@ struct AlignmentAssistantView: View { #endif } } - diff --git a/CamperMonitor/Views/DeviceDetailView.swift b/CamperMonitor/Views/DeviceDetailView.swift index ad9d236..9495ca0 100644 --- a/CamperMonitor/Views/DeviceDetailView.swift +++ b/CamperMonitor/Views/DeviceDetailView.swift @@ -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,106 +36,121 @@ 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 { - List { - statusSection - // if needsKeyAttention { keyPrompt } + // 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 { + // 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 { - // FridgeControls(device: currentDevice, state: fridge) - // } + // if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus { + // FridgeControls(device: currentDevice, state: fridge) + // } - if currentDevice.role == .leveling { - LevelControls(device: currentDevice, - state: bluetooth.levelStates[device.id] ?? LevelState()) - } + if currentDevice.role == .leveling { + LevelControls(device: currentDevice, + state: bluetooth.levelStates[device.id] ?? LevelState(), + availableHeight: geometry.size.height) + } - if let snapshot, !snapshot.metrics.isEmpty { - Section("Messwerte") { - ForEach(snapshot.metrics) { metric in - LabeledContent(metric.label) { - Text(metric.formattedWithUnit) - .monospacedDigit() - .foregroundStyle(metric.value == nil ? .secondary : .primary) + if let snapshot, !snapshot.metrics.isEmpty { + Section("Messwerte") { + ForEach(snapshot.metrics) { metric in + LabeledContent(metric.label) { + Text(metric.formattedWithUnit) + .monospacedDigit() + .foregroundStyle(metric.value == nil ? .secondary : .primary) + } } } } - } - if samples.count > 1, let primary = snapshot?.primaryMetric { - Section("Verlauf – \(primary.label)") { - Chart(samples) { sample in - AreaMark(x: .value("Zeit", sample.time), - y: .value(primary.label, sample.value)) - .foregroundStyle(.tint.opacity(0.15)) - LineMark(x: .value("Zeit", sample.time), - y: .value(primary.label, sample.value)) - .foregroundStyle(.tint) - .interpolationMethod(.monotone) - } - .chartYAxisLabel(primary.unit) - .frame(height: 180) - .padding(.vertical, 8) - } - } - - if let snapshot, !snapshot.cellVoltages.isEmpty { - cellSection(snapshot.cellVoltages) - } - - if let snapshot, !snapshot.info.isEmpty { - Section("Gerät") { - ForEach(snapshot.info) { item in - LabeledContent(item.label, value: item.value) + if samples.count > 1, let primary = snapshot?.primaryMetric { + Section("Verlauf – \(primary.label)") { + Chart(samples) { sample in + AreaMark(x: .value("Zeit", sample.time), + y: .value(primary.label, sample.value)) + .foregroundStyle(.tint.opacity(0.15)) + LineMark(x: .value("Zeit", sample.time), + y: .value(primary.label, sample.value)) + .foregroundStyle(.tint) + .interpolationMethod(.monotone) + } + .chartYAxisLabel(primary.unit) + .frame(height: 180) + .padding(.vertical, 8) } } - } - /* if let snapshot, snapshot.temperatures.count > 1 { - Section("Temperaturen") { - ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in - LabeledContent("Fühler \(index + 1)") { - Text(String(format: "%.0f °C", value)).monospacedDigit() + if let snapshot, !snapshot.cellVoltages.isEmpty { + cellSection(snapshot.cellVoltages) + } + + if let snapshot, !snapshot.info.isEmpty { + Section("Gerät") { + ForEach(snapshot.info) { item in + LabeledContent(item.label, value: item.value) } } } - } - if currentDevice.role.transport == .advertisement { - if showsTechnicalDetails { diagnosticsSection } - } else if showsTechnicalDetails { - bmsDiagnosticsSection - } + if isLandscape { statusSection } - settingsSection*/ - } - .navigationTitle(currentDevice.name) - .navigationBarTitleDisplayMode(.inline) - .onDisappear { - saveName() - // Die Kühlbox wird nur verbunden, solange man sie ansieht – jede - // Verbindung meldet sich an ihrem Display an. - //bluetooth.endSession(for: currentDevice) - } - .onAppear { - editedName = currentDevice.name - keyInput = store.victronKeyText(for: device.id) ?? "" - //bluetooth.beginSession(for: currentDevice) - } - .confirmationDialog("Gerät entfernen?", - isPresented: $showDeleteConfirmation, - titleVisibility: .visible) { - Button("Entfernen", role: .destructive) { - store.remove(device) - bluetooth.refreshConfiguration() - dismiss() + /* if let snapshot, snapshot.temperatures.count > 1 { + Section("Temperaturen") { + ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in + LabeledContent("Fühler \(index + 1)") { + Text(String(format: "%.0f °C", value)).monospacedDigit() + } + } + } + } + + if currentDevice.role.transport == .advertisement { + if showsTechnicalDetails { diagnosticsSection } + } else if showsTechnicalDetails { + bmsDiagnosticsSection + } + + settingsSection*/ + } + .navigationTitle(currentDevice.name) + .navigationBarTitleDisplayMode(.inline) + .onDisappear { + saveName() + // Die Kühlbox wird nur verbunden, solange man sie ansieht – jede + // Verbindung meldet sich an ihrem Display an. + //bluetooth.endSession(for: currentDevice) + } + .onAppear { + editedName = currentDevice.name + keyInput = store.victronKeyText(for: device.id) ?? "" + //bluetooth.beginSession(for: currentDevice) + } + .confirmationDialog("Gerät entfernen?", + isPresented: $showDeleteConfirmation, + titleVisibility: .visible) { + Button("Entfernen", role: .destructive) { + store.remove(device) + bluetooth.refreshConfiguration() + dismiss() + } + } message: { + Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.") } - } message: { - Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.") } } diff --git a/CamperMonitor/Views/LevelView.swift b/CamperMonitor/Views/LevelView.swift index 1444c37..7fbc984 100644 --- a/CamperMonitor/Views/LevelView.swift +++ b/CamperMonitor/Views/LevelView.swift @@ -105,47 +105,76 @@ 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) + if isLandscape { + HStack(alignment: .center, spacing: 20) { + display + .frame(maxWidth: .infinity) + + 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, + systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle") + .font(.headline) + .foregroundStyle(state.isLevel ? Color.green : Color.primary) + .multilineTextAlignment(.center) + } + + HStack(spacing: 24) { + reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch)) + reading("Quer", LevelDirectionFormatting.rollTile(state.roll)) } } - .pickerStyle(.segmented) - - switch displayStyle { - case .bubble: - LevelBubble(pitch: state.pitch, roll: state.roll) - .frame(maxHeight: 220) - case .vehicle: - VehicleTiltView(pitch: state.pitch, roll: state.roll, - style: device.vehicleGraphicStyle) - } - - if let instruction = state.instruction { - Label(instruction, - systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle") - .font(.headline) - .foregroundStyle(state.isLevel ? Color.green : Color.primary) - .multilineTextAlignment(.center) - } - - HStack(spacing: 24) { - reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch)) - reading("Quer", LevelDirectionFormatting.rollTile(state.roll)) - } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) } Section { @@ -220,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 { diff --git a/CamperMonitor/Views/VehicleTiltView.swift b/CamperMonitor/Views/VehicleTiltView.swift index a315d67..8c4c6ef 100644 --- a/CamperMonitor/Views/VehicleTiltView.swift +++ b/CamperMonitor/Views/VehicleTiltView.swift @@ -16,31 +16,38 @@ 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 { - VStack(spacing: 20) { - tiltPanel(image: style.sideImageName, - angle: pitch, - title: "Längs", - lowerLabel: "Front", - upperLabel: "Heck", - aspect: style.sideAspect) + 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: 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) - 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 – " - + "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.", - VehicleTilt.exaggeration)) - .font(.caption2) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) + // Ohne diesen Hinweis nähme man den Bildwinkel für den echten. + Text(String(format: "Neigung %.0f-fach überhöht dargestellt – " + + "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.", + VehicleTilt.exaggeration)) + .font(.caption2) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } } } @@ -50,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)) } @@ -76,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) } }