forked from fritob/Camper-Monitor
App-Namen im iOS-Projekt auf VanControl vereinheitlichen
CamperMonitor (Haupt-Repo) und VanAligneiOS (aus dem gemergten solar-integration-Branch) liefen unter zwei verschiedenen internen Namen, obwohl die App nach aussen längst einheitlich "VanControl Pro" heisst. Jetzt durchgängig VanControl: - Ordner: CamperMonitor/, CamperMonitorWatch/, CamperMonitorComplication/, VanAligneiOSWidget/ → VanControl/, VanControlWatch/, VanControlComplication/, VanControlWidget/ - Xcode-Projekt: CamperMonitor.xcodeproj → VanControl.xcodeproj, alle Targets/Schemes/Produktnamen entsprechend umbenannt - Bundle-Identifier auf Wunsch mitgeändert: de.s0.fototeddy.VanControl* (App noch nicht veröffentlicht); dabei auch die WKCompanionAppBundleIdentifier-Werte korrigiert, die noch das alte de.fritob-Präfix statt des tatsächlichen de.s0.fototeddy-Präfixes trugen - Swift-Dateien/Typen: CamperMonitorApp → VanControlApp, VanAligneiOSWidget* → VanControlWidget* - Config/*-Info.plist umbenannt, README.md/Tools/README.md/run-tests.sh auf die neuen Pfade angepasst Bewusst unverändert: firmware/vanalign und alle Bezüge auf "VanAlign" als Namen der Neigungsmesser-Hardware (eigenständiges Produkt, kein App-Name) sowie der komplette Android/-Ordner. Build (App, Watch, Debug) und Protokoll-Testlauf grün. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
83ea85f3b8
commit
303a9735d0
@@ -0,0 +1,303 @@
|
||||
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
|
||||
/// 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 {
|
||||
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))
|
||||
}
|
||||
}
|
||||
.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.")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(isOn: liveActivityBinding) {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Live Activity")
|
||||
Text("Sperrbildschirm, Dynamic Island und CarPlay")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "widget.small")
|
||||
}
|
||||
}
|
||||
// Ohne laufende Messwerte gäbe es nichts anzuzeigen – und beim
|
||||
// Umschalten hätte die Activity sofort einen veralteten Stand.
|
||||
.disabled(!isLive || !state.hasReading)
|
||||
} footer: {
|
||||
Text(!isLive
|
||||
? "Braucht laufende Messwerte. Der Neigungsmesser ist gerade nicht verbunden."
|
||||
: "Zeigt die Neigung, solange sie läuft – auch bei gesperrtem Bildschirm. "
|
||||
+ "Ist das iPhone mit CarPlay verbunden, erscheint sie automatisch auch dort.")
|
||||
}
|
||||
|
||||
// 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 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> {
|
||||
Binding(
|
||||
get: { levelActivity.isActive(for: device.id) },
|
||||
set: { isOn in
|
||||
if isOn {
|
||||
levelActivity.start(deviceID: device.id, deviceName: device.name, state: state)
|
||||
} else {
|
||||
levelActivity.end()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// 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, _ text: String) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(text)
|
||||
.font(.title2.weight(.semibold).monospacedDigit())
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user