Compare commits
1
Commits
0fdef337fc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2638438494 |
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user