first commit
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Grafische Libelle: eine Blase, die zeigt, wohin das Fahrzeug hängt.
|
||||
///
|
||||
/// Die Blase wandert dorthin, wo das Fahrzeug **höher** steht – so, wie sich
|
||||
/// eine echte Wasserwaage verhält. Wer sie mittig haben will, muss also die
|
||||
/// Gegenseite anheben.
|
||||
struct LevelBubble: View {
|
||||
let pitch: Double?
|
||||
let roll: Double?
|
||||
/// Bis zu welcher Neigung die Anzeige ausschlägt.
|
||||
var range: Double = 6
|
||||
|
||||
private var isLevel: Bool {
|
||||
guard let pitch, let roll else { return false }
|
||||
return abs(pitch) <= LevelState.levelTolerance
|
||||
&& abs(roll) <= LevelState.levelTolerance
|
||||
}
|
||||
|
||||
/// Grün nur, wenn es wirklich eben ist. Die Akzentfarbe der App ist selbst
|
||||
/// grün – damit sähe "schief" genauso aus wie "eben".
|
||||
private var bubbleColor: Color {
|
||||
guard let deviation = maxDeviation else { return .secondary }
|
||||
if deviation <= LevelState.levelTolerance { return .green }
|
||||
if deviation <= 2 { return .orange }
|
||||
return .red
|
||||
}
|
||||
|
||||
private var maxDeviation: Double? {
|
||||
switch (pitch, roll) {
|
||||
case let (p?, r?): return max(abs(p), abs(r))
|
||||
case let (p?, nil): return abs(p)
|
||||
case let (nil, r?): return abs(r)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let side = min(geometry.size.width, geometry.size.height)
|
||||
let radius = side / 2
|
||||
let bubble = side * 0.16
|
||||
// Die Blase darf den Rand nicht verlassen, auch bei starker Neigung.
|
||||
let travel = radius - bubble / 2 - 4
|
||||
|
||||
let offsetX = clamped(roll) / range * travel
|
||||
let offsetY = clamped(pitch) / range * travel
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(.secondarySystemFill))
|
||||
Circle()
|
||||
.strokeBorder(Color.secondary.opacity(0.35), lineWidth: 1)
|
||||
|
||||
// Ringe als echter Maßstab: der innere markiert die Toleranz,
|
||||
// der mittlere zwei Grad. Ohne Maßstab sagt die Blasenlage
|
||||
// nichts darüber, wie weit es noch ist.
|
||||
let toleranceRadius = max(LevelState.levelTolerance / range * travel, bubble * 0.6)
|
||||
Circle()
|
||||
.strokeBorder(isLevel ? Color.green : Color.secondary.opacity(0.5),
|
||||
lineWidth: isLevel ? 2 : 1)
|
||||
.frame(width: toleranceRadius * 2, height: toleranceRadius * 2)
|
||||
Circle()
|
||||
.strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1)
|
||||
.frame(width: 2.0 / range * travel * 2, height: 2.0 / range * travel * 2)
|
||||
|
||||
Path { path in
|
||||
path.move(to: CGPoint(x: radius - travel, y: radius))
|
||||
path.addLine(to: CGPoint(x: radius + travel, y: radius))
|
||||
path.move(to: CGPoint(x: radius, y: radius - travel))
|
||||
path.addLine(to: CGPoint(x: radius, y: radius + travel))
|
||||
}
|
||||
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
|
||||
|
||||
Circle()
|
||||
.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)
|
||||
.animation(.spring(duration: 0.35), value: offsetX)
|
||||
.animation(.spring(duration: 0.35), value: offsetY)
|
||||
.opacity(pitch == nil && roll == nil ? 0.25 : 1)
|
||||
}
|
||||
.frame(width: side, height: side)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.accessibilityLabel(accessibilityText)
|
||||
}
|
||||
|
||||
private func clamped(_ value: Double?) -> Double {
|
||||
guard let value else { return 0 }
|
||||
return min(max(value, -range), range)
|
||||
}
|
||||
|
||||
private var accessibilityText: String {
|
||||
guard let pitch, let roll else { return "Keine Messwerte" }
|
||||
return String(format: "Längsneigung %.1f Grad, Querneigung %.1f Grad", pitch, roll)
|
||||
}
|
||||
}
|
||||
|
||||
/// Anzeige und Kalibrierung des Neigungsmessers.
|
||||
struct LevelControls: View {
|
||||
let device: ConfiguredDevice
|
||||
let state: LevelState
|
||||
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@Environment(DeviceStore.self) private var store
|
||||
@State private var showAssistant = false
|
||||
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
VStack(spacing: 16) {
|
||||
Picker("Darstellung", selection: $displayStyle) {
|
||||
ForEach(LevelDisplayStyle.allCases) { style in
|
||||
Text(style.title).tag(style)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
|
||||
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", state.pitch)
|
||||
reading("Quer", state.roll)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
showAssistant = true
|
||||
} label: {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Ausrichtungs-Assistent")
|
||||
Text("Begleitet das Rangieren")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "car.side.arrowtriangle.up.arrowtriangle.down")
|
||||
}
|
||||
}
|
||||
// Der Assistent lebt von laufenden Messwerten. Ein einmal
|
||||
// empfangener Wert genügt nicht: nach einem Verbindungsabbruch
|
||||
// bliebe er stehen und die Ansicht sähe eingefroren aus.
|
||||
.disabled(!isLive || !state.hasReading)
|
||||
// Der Vollbildschirm gehört an den Knopf, nicht an den Abschnitt.
|
||||
// SwiftUI zeigt Blätter und Vollbildschirme an einer `Section`
|
||||
// nicht zuverlässig an – dasselbe hatte die Kühlbox schon.
|
||||
.fullScreenCover(isPresented: $showAssistant) {
|
||||
AlignmentAssistantView(device: device, profile: store.activeProfile)
|
||||
}
|
||||
} footer: {
|
||||
if !isLive {
|
||||
Text("Der Assistent braucht laufende Messwerte. Der Neigungsmesser "
|
||||
+ "ist gerade nicht verbunden.")
|
||||
}
|
||||
}
|
||||
|
||||
// Einbaulage und Nullpunkt liegen auf einer eigenen Seite. Direkt unter
|
||||
// der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt.
|
||||
Section {
|
||||
NavigationLink {
|
||||
LevelSetupView(device: device)
|
||||
} label: {
|
||||
LabeledContent {
|
||||
Text(setupSummary).font(.caption)
|
||||
} label: {
|
||||
Label("Neigungsmesser einrichten", systemImage: "slider.horizontal.3")
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Einbaulage des Sensors und Nullpunkt der Anzeige.")
|
||||
}
|
||||
}
|
||||
|
||||
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
|
||||
|
||||
/// Was auf der Einrichtungsseite zu holen ist – der Nullpunkt zuerst, denn
|
||||
/// ohne ihn stimmt die Anzeige nicht.
|
||||
private var setupSummary: String {
|
||||
state.isKnownUncalibrated ? "Nullpunkt fehlt" : device.sensorOrientation.summary
|
||||
}
|
||||
|
||||
private func reading(_ title: String, _ value: Double?) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(value.map { String(format: "%.1f°", $0) } ?? "–")
|
||||
.font(.title2.weight(.semibold).monospacedDigit())
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user