Compare commits

1 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
4 changed files with 339 additions and 196 deletions
+123 -54
View File
@@ -11,6 +11,7 @@ struct AlignmentAssistantView: View {
@Environment(BluetoothManager.self) private var bluetooth @Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var assistant = AlignmentAssistant() @State private var assistant = AlignmentAssistant()
@State private var didAnnounceTarget = false @State private var didAnnounceTarget = false
@@ -18,50 +19,19 @@ struct AlignmentAssistantView: View {
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() } 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 { var body: some View {
NavigationStack { NavigationStack {
ScrollView { Group {
VStack(spacing: 24) { if isLandscape {
Picker("Darstellung", selection: $displayStyle) { landscapeLayout
ForEach(LevelDisplayStyle.allCases) { style in } else {
Text(style.title).tag(style) 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,
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)
}
.padding(.horizontal)
.frame(maxWidth: .infinity)
}
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
.navigationTitle("Ausrichtungs-Assistent") .navigationTitle("Ausrichtungs-Assistent")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -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 // MARK: - Bausteine
private var isLive: Bool { bluetooth.linkStates[device.id] == .live } 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 /// 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 /// Hinweis sähe das aus, als hinge die App man rangiert dann nach einem
/// Wert, der längst nicht mehr gilt. /// Wert, der längst nicht mehr gilt.
@@ -121,40 +175,54 @@ struct AlignmentAssistantView: View {
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 16)) .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 { private var adviceBanner: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: assistant.hasReachedTarget Image(systemName: assistant.hasReachedTarget
? "checkmark.circle.fill" : assistant.trend.symbol) ? "checkmark.circle.fill" : assistant.trend.symbol)
.font(.title) .font(isLandscape ? .title2 : .title)
.foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor) .foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor)
Text(assistant.advice) Text(assistant.advice)
.font(.title3.weight(.medium)) .font(isLandscape ? .subheadline.weight(.medium) : .title3.weight(.medium))
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
.padding() .padding(isLandscape ? 10 : 16)
.background(assistant.hasReachedTarget ? Color.green.opacity(0.15) .background(assistant.hasReachedTarget ? Color.green.opacity(0.15)
: Color(.secondarySystemGroupedBackground), : Color(.secondarySystemGroupedBackground),
in: .rect(cornerRadius: 16)) in: .rect(cornerRadius: 16))
} }
private var readings: some View { private var readings: some View {
HStack(spacing: 12) { HStack(spacing: isLandscape ? 8 : 12) {
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch)) reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
reading("Quer", LevelDirectionFormatting.rollTile(state.roll)) reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
if !isLandscape {
reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation)) reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation))
} }
} }
}
private func reading(_ title: String, _ text: String) -> some View { private func reading(_ title: String, _ text: String) -> some View {
VStack(spacing: 4) { VStack(spacing: isLandscape ? 1 : 4) {
Text(text) Text(text)
.font(.title2.weight(.semibold).monospacedDigit()) .font((isLandscape ? Font.callout : .title2).weight(.semibold).monospacedDigit())
Text(title) Text(title)
.font(.caption) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 12) .padding(.vertical, isLandscape ? 6 : 12)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12)) .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
} }
@@ -188,23 +256,25 @@ struct AlignmentAssistantView: View {
trackWidth: width, wheelbase: base) trackWidth: width, wheelbase: base)
}() }()
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: isLandscape ? 6 : 10) {
Label("Auffahrkeile", systemImage: "triangle.fill") Label("Auffahrkeile", systemImage: "triangle.fill")
.font(.headline) .font(isLandscape ? .subheadline.weight(.semibold) : .headline)
if profile?.trackWidth == nil || profile?.wheelbase == nil { if profile?.trackWidth == nil || profile?.wheelbase == nil {
Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt " Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt "
+ "sich beim Fahrzeug hinterlegen.") + "sich beim Fahrzeug hinterlegen.")
.font(.callout) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else if let lift, !lift.isNegligible { } else if let lift, !lift.isNegligible {
WheelLiftPlan(lift: lift) WheelLiftPlan(lift: lift, isCompact: isLandscape)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
if !isLandscape {
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad " Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.") + "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
}
} else { } else {
Text("Keine Keile nötig.") Text("Keine Keile nötig.")
.font(.callout) .font(.callout)
@@ -212,7 +282,7 @@ struct AlignmentAssistantView: View {
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding() .padding(isLandscape ? 10 : 16)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16)) .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
} }
@@ -230,4 +300,3 @@ struct AlignmentAssistantView: View {
#endif #endif
} }
} }
+18 -2
View File
@@ -7,6 +7,7 @@ struct DeviceDetailView: View {
@Environment(DeviceStore.self) private var store @Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth @Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var editedName = "" @State private var editedName = ""
/// Nur zum Vergleich mit dem, was das Gerät sendet. Eingetragen wird der /// 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 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 snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching } private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] } private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] }
var body: some View { 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 { 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 needsKeyAttention { keyPrompt }
// if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus { // if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
@@ -50,7 +62,8 @@ struct DeviceDetailView: View {
if currentDevice.role == .leveling { if currentDevice.role == .leveling {
LevelControls(device: currentDevice, 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 { if let snapshot, !snapshot.metrics.isEmpty {
@@ -94,6 +107,8 @@ struct DeviceDetailView: View {
} }
} }
if isLandscape { statusSection }
/* if let snapshot, snapshot.temperatures.count > 1 { /* if let snapshot, snapshot.temperatures.count > 1 {
Section("Temperaturen") { Section("Temperaturen") {
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in 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.") Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
} }
} }
}
// MARK: - Abschnitte // MARK: - Abschnitte
+65 -14
View File
@@ -105,31 +105,59 @@ struct LevelBubble: View {
struct LevelControls: View { struct LevelControls: View {
let device: ConfiguredDevice let device: ConfiguredDevice
let state: LevelState 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(BluetoothManager.self) private var bluetooth
@Environment(DeviceStore.self) private var store @Environment(DeviceStore.self) private var store
@Environment(LevelActivityManager.self) private var levelActivity @Environment(LevelActivityManager.self) private var levelActivity
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var showAssistant = false @State private var showAssistant = false
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble @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 { var body: some View {
Section { Section {
VStack(spacing: 16) { if isLandscape {
Picker("Darstellung", selection: $displayStyle) { HStack(alignment: .center, spacing: 20) {
ForEach(LevelDisplayStyle.allCases) { style in display
Text(style.title).tag(style) .frame(maxWidth: .infinity)
}
}
.pickerStyle(.segmented)
switch displayStyle { VStack(spacing: 12) {
case .bubble: picker
LevelBubble(pitch: state.pitch, roll: state.roll) if let instruction = state.instruction {
.frame(maxHeight: 220) Label(instruction,
case .vehicle: systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
VehicleTiltView(pitch: state.pitch, roll: state.roll, .font(.subheadline.weight(.semibold))
style: device.vehicleGraphicStyle) .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 { if let instruction = state.instruction {
Label(instruction, Label(instruction,
@@ -147,6 +175,7 @@ struct LevelControls: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 8) .padding(.vertical, 8)
} }
}
Section { Section {
Button { Button {
@@ -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 isLive: Bool { bluetooth.linkStates[device.id] == .live }
private var liveActivityBinding: Binding<Bool> { private var liveActivityBinding: Binding<Bool> {
+25 -18
View File
@@ -16,22 +16,28 @@ struct VehicleTiltView: View {
let pitch: Double? let pitch: Double?
let roll: Double? let roll: Double?
var style: VehicleGraphicStyle = .vanster 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 { 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) { VStack(spacing: 20) {
tiltPanel(image: style.sideImageName, tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
angle: pitch, lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
title: "Längs", tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
lowerLabel: "Front", lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
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. // Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
Text(String(format: "Neigung %.0f-fach überhöht dargestellt " Text(String(format: "Neigung %.0f-fach überhöht dargestellt "
@@ -43,6 +49,7 @@ struct VehicleTiltView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
} }
}
private func tiltPanel(image: String, private func tiltPanel(image: String,
angle: Double?, angle: Double?,
@@ -50,13 +57,13 @@ struct VehicleTiltView: View {
lowerLabel: String, lowerLabel: String,
upperLabel: String, upperLabel: String,
aspect: Double) -> some View { aspect: Double) -> some View {
VStack(spacing: 8) { VStack(spacing: compact ? 4 : 8) {
HStack { HStack {
Text(title) Text(title)
.font(.subheadline.weight(.medium)) .font(compact ? .caption2.weight(.medium) : .subheadline.weight(.medium))
Spacer() Spacer()
Text(angle.map { String(format: "%.1f°", $0) } ?? "") 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)) .foregroundStyle(VehicleTilt.colour(for: angle))
} }
@@ -76,14 +83,14 @@ struct VehicleTiltView: View {
.animation(.spring(duration: 0.4), value: angle) .animation(.spring(duration: 0.4), value: angle)
.opacity(angle == nil ? 0.3 : 1) .opacity(angle == nil ? 0.3 : 1)
} }
.frame(height: 110) .frame(height: compact ? compactPanelHeight : 110)
HStack { HStack {
Text(lowerLabel) Text(lowerLabel)
Spacer() Spacer()
Text(upperLabel) Text(upperLabel)
} }
.font(.caption2) .font(compact ? .system(size: 9) : .caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }